Crea arte ASCII a partir de videos con Lua y FFmpeg
Usa Lua para convertir el brillo de cada fotograma en texto y luego renderiza esos caracteres en un video de arte ASCII sin audio. El siguiente ejemplo produce una cuadrícula de texto de 80 columnas y un MP4 de 640 × 360 a 10 fotogramas por segundo, con FFmpeg para decodificar e ImageMagick para dibujar el texto.
Elige un video de entrada corto
Empieza con un video SDR corto, intacto y con píxeles cuadrados. Los rostros, las siluetas y las formas grandes con contraste se conservan mejor durante la conversión que las letras pequeñas o los fondos recargados. Este es un efecto visual basado en muestreo: descarta el audio, el color y los detalles, y no conserva todos los fotogramas de origen.
El flujo de trabajo se ejecuta localmente en Linux con Bash. Guarda fotogramas PNG, texto ASCII, PNG renderizados y el video final, así que usa un clip corto mientras eliges el aspecto. Procesa los fotogramas secuencialmente y se detiene en el primer fallo detectado.
Comprueba las herramientas y crea un directorio de trabajo
Necesitas Bash, Lua, FFmpeg y ffprobe, el comando magick de ImageMagick 7
y un archivo de fuente monoespaciada legible. No necesitas LuaRocks ni bibliotecas de imágenes para
Lua. Los comandos de este documento se probaron con Lua 5.5.1, FFmpeg/ffprobe 9.0.1,
ImageMagick 7.1.2-31 y Liberation Mono; el programa Lua también se volvió a ejecutar con Lua 5.4.8,
y el flujo de trabajo, con FFmpeg/ffprobe 6.1.1. Estas son las versiones probadas; no se afirma que
se haya probado cada versión intermedia ni otro sistema operativo. Para una nueva instalación de
Lua, usa la versión actual: Lua 5.4 ha llegado a su versión final.
Instala estas herramientas mediante tu distribución de Linux o sus descargas oficiales. Tu
compilación de FFmpeg debe incluir el codificador libx264, e ImageMagick debe
admitir PNG y renderizado de texto. Comprueba los comandos antes de continuar:
lua -v && ffmpeg -version && ffprobe -version && magick -version
Crea un directorio nuevo y entra en él solo si se crea correctamente. Si
ascii-demo ya existe, elige otro nombre; no elimines un proyecto existente para
seguir el tutorial.
mkdir -- ascii-demo && cd -- ascii-demo
Copia tu clip en este directorio como input.mp4. Si quieres un punto de partida
reproducible, este comando crea en su lugar un patrón de prueba de 1,3 segundos. No reemplaza un
input.mp4 existente:
ffmpeg -nostdin -v error -n -f lavfi -i "testsrc2=size=320x180:rate=10:duration=1.3" \
-c:v libx264 -threads 1 -pix_fmt yuv420p input.mp4
Extrae fotogramas del video con FFmpeg
Para obtener una vista previa de un solo fotograma, extrae una secuencia nueva. Los paréntesis
mantienen las opciones del shell dentro de este bloque, y mkdir no permite
reutilizar un directorio frames existente.
(
set -e
mkdir -- frames
ffmpeg -nostdin -v error -xerror -n -i ./input.mp4 -map 0:v:0 \
-vf "fps=10,scale=640:-2,setsar=1" -threads 1 -filter_threads 1 \
-start_number 1 frames/frame_%08d.png
)
El filtro fps de FFmpeg muestrea a 10 FPS.
scale=640:-2 mantiene las proporciones de entrada y elige una altura par, y
setsar=1 marca los píxeles resultantes como cuadrados. Un clip de 16:9 produce
PNG de 640 × 360 llamados frame_00000001.png, frame_00000002.png, etc.
El clip de prueba proporcionado produce 13 fotogramas. El muestreo redondea las marcas de tiempo
para ajustarlas a una cuadrícula de fotogramas; la duración de una entrada cualquiera puede diferir
de la del resultado en aproximadamente un intervalo de fotograma de 0,1 segundos.
Convierte un PNG en ASCII con Lua
Guarda este programa completo como ascii.lua en ascii-demo.
Analiza el PNG, solicita a FFmpeg un fotograma RGB escalado y comprueba que haya llegado cada píxel
esperado antes de construir el texto.
-- ascii.lua
local chars = " .:-=+*#@" -- Sparse to dense: white glyphs on a black background.
local columns = 80
local cellRatio = 0.5 -- Approximate character width divided by line height.
local function shellQuote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
local function readCommand(command)
local pipe = assert(io.popen(command, "r"))
local data, readError = pipe:read("*a")
local ok, reason, status = pipe:close()
assert(data, readError)
assert(ok, string.format("Subprocess failed (%s %s)", reason, status))
return data
end
local function main()
assert(arg[1] and not arg[3], "Usage: lua ascii.lua <PNG> [output.txt]")
local input = arg[1]
if input:sub(1, 1) ~= "/" then input = "./" .. input end
assert(input:match("%.png$"), "Input must be a local .png file")
assert(arg[2] ~= arg[1], "Input and output must be different files")
local quoted = shellQuote(input)
local dimensions = readCommand(
"ffprobe -v error -f image2 -pattern_type none -select_streams v:0 " ..
"-show_entries stream=width,height -of csv=s=x:p=0 -i " .. quoted
)
local width, height = dimensions:match("^(%d+)x(%d+)%s*$")
width, height = tonumber(width), tonumber(height)
assert(width and height and width > 0 and height > 0, "Invalid PNG dimensions")
local rows = math.max(1, math.floor(height / width * columns * cellRatio + 0.5))
local pixels = readCommand(string.format(
"ffmpeg -nostdin -v error -xerror -err_detect explode -threads 1 " ..
"-f image2 -pattern_type none -i %s -map 0:v:0 " ..
"-vf scale=%d:%d -filter_threads 1 -frames:v 1 " ..
"-threads 1 -f rawvideo -pix_fmt rgb24 -", quoted, columns, rows
))
assert(#pixels == columns * rows * 3, "Incomplete or unexpected RGB pixel data")
local lines = {}
local position = 1
for y = 1, rows do
local line = {}
for x = 1, columns do
local r, g, b = pixels:byte(position, position + 2)
local brightness = 0.299 * r + 0.587 * g + 0.114 * b
local index = math.floor(brightness / 255 * (#chars - 1) + 0.5) + 1
line[x] = chars:sub(index, index)
position = position + 3
end
lines[y] = table.concat(line)
end
local text = table.concat(lines, "\n") .. "\n"
if arg[2] then
local output = assert(io.open(arg[2], "wb"))
local written, writeError = output:write(text)
local closed, closeError = output:close()
assert(written, writeError)
assert(closed, closeError)
else
assert(io.write(text))
assert(io.flush())
end
end
local ok, message = pcall(main)
if not ok then
io.stderr:write("Error: " .. tostring(message) .. "\n")
os.exit(1)
end
Es importante cerrar el pipe: file:close() de Lua
devuelve el estado del subproceso para un manejador abierto con io.popen.
Leer algunos bytes no demuestra por sí solo que FFmpeg haya terminado correctamente. El programa
también comprueba tanto la escritura como el cierre de la salida, de modo que no puede informar
que se guardó correctamente si falta un directorio o el disco está lleno.
Previsualiza el primer fotograma con un fondo oscuro en la terminal:
lua ascii.lua frames/frame_00000001.png
Para guardarlo, crea primero el directorio padre. Este comando no permite reutilizar
ascii_frames; el propio programa Lua reemplaza un archivo de texto de destino
existente una vez que la conversión termina correctamente y comienza la escritura. Una escritura
fallida puede dejar un archivo de texto parcial, así que comprueba el estado de salida del programa
antes de usarlo.
mkdir -- ascii_frames && lua ascii.lua frames/frame_00000001.png ascii_frames/frame_00000001.txt
Para un fotograma de 16:9, el resultado esperado es de 23 filas de 80 caracteres. Los píxeles negros
se convierten en espacios y los blancos en @, mientras que el brillo
intermedio se asigna a los caracteres situados entre ambos. El cálculo de filas compensa de forma
aproximada que los caracteres son más altos que anchos; no es una métrica de la fuente.
Crea un video de arte ASCII
Guarda lo siguiente como create_ascii_video.sh, junto a ascii.lua.
Extrae sus propios fotogramas nuevos del video original, por lo que no utiliza los directorios de
vista previa. Proporciónale una ruta de entrada y un directorio de salida nuevo. Rechaza un destino
existente, conserva los archivos intermedios para inspeccionarlos después de un fallo y publica
ascii_video.mp4 solo después de completar el encoding, contar los fotogramas y
decodificar el video resultante.
Usa un directorio de salida local cuya ruta no contenga el carácter %.
FFmpeg interpreta % en las rutas de secuencias de imágenes, incluso
entre comillas del shell. Se admiten espacios y guiones iniciales. Ejecuta una sola instancia a la
vez y no cambies sus archivos de entrada ni intermedios mientras esté en funcionamiento.
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C
die() { printf 'Error: %s\n' "$*" >&2; exit 1; }
[[ $# -eq 2 ]] || die 'Usage: bash create_ascii_video.sh INPUT_VIDEO NEW_OUTPUT_DIRECTORY'
input=$1
run=$2
[[ $input = /* ]] || input="./$input"
[[ $run = /* ]] || run="./$run"
[[ $run != *%* ]] || die 'Output path must not contain %'
[[ -r $input && -f $input ]] || die 'Input video is not a readable file'
[[ -f ascii.lua ]] || die 'Run from the directory containing ascii.lua'
[[ ! -e $run && ! -L $run ]] || die 'Output directory already exists; choose a new name'
font=${FONT:-/usr/share/fonts/liberation/LiberationMono-Regular.ttf}
[[ -r $font && -f $font ]] || die 'Set FONT to a readable monospaced font file'
for tool in lua ffmpeg ffprobe magick; do
command -v "$tool" >/dev/null || die "Missing tool: $tool"
done
mkdir -- "$run"
mkdir -- "$run/frames" "$run/ascii_frames" "$run/ascii_images"
temporary="$run/ascii_video.part.mp4"
trap 'rm -f -- "$temporary"' EXIT
# A reported FFmpeg error is a failure even if that build returns zero.
run_ffmpeg() {
if ! ffmpeg -nostdin -v error -xerror "$@" 2>"$run/ffmpeg.log"; then
cat -- "$run/ffmpeg.log" >&2
die 'FFmpeg failed'
fi
[[ ! -s $run/ffmpeg.log ]] || { cat -- "$run/ffmpeg.log" >&2; die 'FFmpeg reported an error'; }
}
run_ffmpeg -n -threads 1 -i "$input" -map 0:v:0 \
-vf 'fps=10,scale=640:-2,setsar=1' -threads 1 -filter_threads 1 \
-start_number 1 "$run/frames/frame_%08d.png"
shopt -s nullglob
frames=("$run"/frames/frame_*.png)
[[ ${#frames[@]} -gt 0 ]] || die 'No video frames were extracted'
count=0
for frame in "${frames[@]}"; do
count=$((count + 1))
printf -v name 'frame_%08d' "$count"
[[ $frame = "$run/frames/$name.png" ]] || die 'Frame sequence has a gap'
text="$run/ascii_frames/$name.txt"
image="$run/ascii_images/$name.png"
lua ascii.lua "$frame" "$text"
# Strip only the display copy’s final newline to avoid an extra blank label row.
printf '%s' "$(< "$text")" | magick -background black -fill white \
-font "$font" -pointsize 12 label:@- -resize 640x360 \
-gravity center -extent 640x360 "$image"
printf 'Rendered frame %d/%d\n' "$count" "${#frames[@]}"
done
run_ffmpeg -n -framerate 10 -start_number 1 -start_number_range 1 \
-i "$run/ascii_images/frame_%08d.png" -c:v libx264 -threads 1 \
-pix_fmt yuv420p -crf 18 "$temporary"
decoded=$(ffprobe -v error -select_streams v:0 -count_frames \
-show_entries stream=nb_read_frames -of csv=p=0 "$temporary")
[[ $decoded = "$count" ]] || die 'Encoded frame count does not match extracted frames'
run_ffmpeg -threads 1 -i "$temporary" -map 0:v:0 -f null -
mv -- "$temporary" "$run/ascii_video.mp4"
printf 'Created %s with %d frames at 10 FPS\n' "$run/ascii_video.mp4" "$count"
Establece FONT en la ruta absoluta de una fuente monoespaciada instalada.
El valor predeterminado anterior es la ruta de Linux probada para Liberation Mono; otras
distribuciones pueden ubicarla en otro lugar. Puedes encontrar las fuentes instaladas con las
herramientas de fuentes de tu sistema. Una fuente proporcional desalineará la cuadrícula.
FONT=/usr/share/fonts/liberation/LiberationMono-Regular.ttf \
bash create_ascii_video.sh input.mp4 ascii-run
Abre ascii-run/ascii_video.mp4 en tu reproductor de video. Para el patrón de prueba
proporcionado, la salida tiene 13 fotogramas, dura 1,3 segundos a 10 FPS y contiene un flujo de video
H.264 sin audio. El primer y el último fotograma renderizados son ascii-run/ascii_images/frame_00000001.png y
ascii-run/ascii_images/frame_00000013.png.
El renderizador label: de ImageMagick dibuja la
cuadrícula preformateada sin ajustar las líneas de texto. Redimensionar la etiqueta antes de añadir
márgenes permite que las cuadrículas verticales también quepan en el lienzo. Si ImageMagick deniega
la lectura de @-, la política de seguridad de tu instalación bloquea la
lectura indirecta de texto; usa una instalación local configurada adecuadamente en vez de
desactivar a ciegas su política global.
Ajusta el aspecto
Cambia columns en ascii.lua para captar más o menos
detalles. Más columnas implican glifos más pequeños al ajustarlos al lienzo fijo del video. Ajusta
cellRatio al ancho de los caracteres de la fuente elegida dividido entre su
altura de línea si el sujeto se ve estirado. Para un fondo blanco con texto negro, invierte
chars y cambia a la vez el fondo y el relleno del renderizador.
Si el contraste es débil, prueba a añadir eq=contrast=1.3 después de
setsar=1 en el filtro de extracción y luego ejecuta el script con un directorio
nuevo como destino. Es un ajuste visual, no una garantía de mejora de la calidad. Mantén la misma
tasa de extracción y reproducción: cambiar solo el -framerate final cambia la
velocidad.
Diagnostica una conversión fallida
El rechazo de un directorio de salida existente es deliberado, incluso después de una ejecución
fallida. Inspecciona sus archivos intermedios y ffmpeg.log, y luego vuelve a
intentarlo con un nombre de directorio nuevo. Esto evita que el texto o las imágenes anteriores
alarguen un nuevo clip más corto. Un fallo de Lua detiene el bucle antes de que los fotogramas
válidos posteriores puedan ocultarlo, y un fallo de encoding o de decodificación final no deja
ningún ascii_video.mp4 publicado.
Las comprobaciones detectan subprocesos fallidos, datos RGB incompletos, fallos de escritura de texto y errores notificados en las etapas de FFmpeg del lote. No demuestran que el video de origen estuviera intacto: un decodificador puede recuperar contenido multimedia dañado sin notificar un error. Inspecciona contenido reconocible cerca del final de la entrada y de la salida, así como el número de fotogramas extraídos. Para la secuencia muestreada, la duración del video final es su número de fotogramas dividido entre 10; no es un certificado de integridad de la grabación original.
Cada fotograma inicia herramientas externas y escribe archivos intermedios, por lo que este método es adecuado para clips creativos cortos y no para la reproducción en tiempo real. Empieza con una cuadrícula legible y unos pocos segundos de video; amplía el clip cuando el primer y el último fotograma muestren el efecto que quieres.
