Crie arte ASCII a partir de vídeos com Lua e FFmpeg
Use Lua para mapear o brilho de cada quadro do vídeo em texto e, depois, renderize esses caracteres em um vídeo de arte ASCII sem áudio. O exemplo abaixo produz uma grade de texto de 80 colunas e um MP4 de 640 × 360 a 10 quadros por segundo, usando o FFmpeg para decodificar e o ImageMagick para desenhar o texto.
Escolha um vídeo de entrada curto
Comece com um vídeo SDR curto e íntegro, com pixels quadrados. Rostos, silhuetas e formas grandes e contrastantes resistem melhor à conversão do que letras pequenas ou fundos carregados. Este é um efeito visual por amostragem: ele descarta áudio, cor e detalhes, e não preserva todos os quadros da origem.
O fluxo de trabalho roda localmente no Linux, em Bash. Ele salva quadros PNG, texto ASCII, PNGs renderizados e o vídeo final, então use um clipe curto enquanto escolhe o visual. Ele processa os quadros em sequência e para na primeira falha detectada.
Verifique as ferramentas e crie um diretório de trabalho
Você precisa de Bash, Lua, FFmpeg e ffprobe, do comando magick do ImageMagick 7 e de um arquivo de
fonte monoespaçada legível. LuaRocks e bibliotecas de imagem para Lua são desnecessários. Os comandos
aqui foram testados com Lua 5.5.1, FFmpeg/ffprobe 9.0.1, ImageMagick 7.1.2-31 e Liberation Mono; o
programa em Lua também foi executado novamente com Lua 5.4.8, e o fluxo de trabalho, com
FFmpeg/ffprobe 6.1.1. Estas são as versões testadas, e não uma afirmação de que todas as versões
intermediárias ou outro sistema operacional foram testados. Para uma nova instalação de Lua, use a
versão atual: Lua 5.4 chegou à sua versão final.
Instale essas ferramentas pela sua distribuição Linux ou pelos downloads oficiais. Seu build do
FFmpeg precisa incluir o encoder libx264, e o ImageMagick precisa oferecer suporte a PNG e à
renderização de texto. Verifique os comandos antes de continuar:
lua -v && ffmpeg -version && ffprobe -version && magick -version
Crie um novo diretório e entre nele somente se a criação for bem-sucedida. Se ascii-demo já
existir, escolha outro nome; não exclua um projeto existente para seguir o tutorial.
mkdir -- ascii-demo && cd -- ascii-demo
Copie seu clipe para este diretório como input.mp4. Se quiser um ponto de partida reproduzível,
este comando cria, em vez disso, um padrão de teste de 1,3 segundo. Ele se recusa a substituir um
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
Extraia quadros de vídeo com o FFmpeg
Para uma prévia de um único quadro, extraia uma sequência nova. Os parênteses mantêm as opções do
shell locais a este bloco, e mkdir se recusa a reutilizar um diretório 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
)
O filtro fps do FFmpeg faz a amostragem a 10 FPS. scale=640:-2
mantém as proporções da entrada ao escolher uma altura par, e setsar=1 marca os pixels
resultantes como quadrados. Um clipe 16:9 produz PNGs de 640 × 360 chamados frame_00000001.png,
frame_00000002.png e assim por diante. O clipe de teste fornecido produz 13 quadros. A amostragem
arredonda os timestamps para uma grade de quadros; durações arbitrárias de entrada podem diferir do
resultado em aproximadamente um intervalo de quadro de 0,1 segundo.
Converta um PNG em ASCII com Lua
Salve este programa completo como ascii.lua em ascii-demo. Ele inspeciona o PNG, pede ao
FFmpeg um quadro RGB redimensionado e verifica se todos os pixels esperados chegaram antes de montar
o 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
Fechar o pipe é importante: o file:close() do Lua
retorna o status do subprocesso para um handle aberto com io.popen. Ler alguns bytes, por si
só, não prova que o FFmpeg teve sucesso. O programa também verifica tanto a escrita quanto o
fechamento da saída, para que um diretório ausente ou um disco cheio não possa ser relatado como um
salvamento bem-sucedido.
Visualize o primeiro quadro em um terminal com fundo escuro:
lua ascii.lua frames/frame_00000001.png
Para salvá-lo, crie primeiro o diretório pai. Este comando se recusa a reutilizar ascii_frames; o
próprio programa em Lua substitui um arquivo de texto de destino existente assim que a conversão é
bem-sucedida e a escrita começa. Uma escrita com falha pode deixar um arquivo de texto parcial, então
verifique o status de saída antes de usá-lo.
mkdir -- ascii_frames && lua ascii.lua frames/frame_00000001.png ascii_frames/frame_00000001.txt
Para um quadro 16:9, espere 23 linhas de 80 caracteres. Pixels pretos viram espaços e pixels brancos
viram @, com brilhos intermediários mapeados para os caracteres entre eles. O cálculo de
linhas compensa aproximadamente o fato de os caracteres serem mais altos do que largos; ele não é uma
métrica da fonte.
Crie um vídeo de arte ASCII
Salve o seguinte como create_ascii_video.sh, ao lado de ascii.lua. Ele extrai seus próprios quadros
novos do vídeo original, portanto não consome os diretórios de prévia. Informe um caminho de entrada
e um novo diretório de saída. Ele recusa um destino existente, mantém os arquivos intermediários para
inspeção após uma falha e publica ascii_video.mp4 somente depois de codificar, contar e
decodificar o vídeo resultante.
Use um diretório de saída local cujo caminho não contenha o caractere %. O FFmpeg interpreta
% em caminhos de sequências de imagens, mesmo dentro de aspas do shell. Espaços e hifens
iniciais são suportados. Execute uma instância por vez e não altere a entrada nem os arquivos
intermediários enquanto ela estiver em execução.
#!/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"
Defina FONT como o caminho absoluto de uma fonte monoespaçada instalada. O padrão acima é o
caminho testado no Linux para Liberation Mono; outras distribuições podem colocá-la em outro lugar.
Você pode encontrar as fontes instaladas com as ferramentas de fontes do seu sistema. Uma fonte
proporcional vai desalinhar a grade.
FONT=/usr/share/fonts/liberation/LiberationMono-Regular.ttf \
bash create_ascii_video.sh input.mp4 ascii-run
Abra ascii-run/ascii_video.mp4 no seu player de vídeo. Para o padrão de teste fornecido, a saída tem
13 quadros, dura 1,3 segundo a 10 FPS e contém um fluxo de vídeo H.264, sem áudio. O primeiro e o
último quadros renderizados são ascii-run/ascii_images/frame_00000001.png e
ascii-run/ascii_images/frame_00000013.png.
O renderizador label: do ImageMagick desenha a grade
pré-formatada sem quebra automática de linha. Redimensionar o rótulo antes de aplicar o preenchimento
também mantém grades em orientação retrato dentro da tela. Se o ImageMagick negar a leitura de
@-, a política de segurança da sua instalação está bloqueando a indireção de texto; use uma
instalação local configurada adequadamente em vez de desativar a política global às cegas.
Ajuste a aparência
Altere columns em ascii.lua para capturar mais ou menos detalhes. Mais colunas significam
glifos menores quando ajustados à tela fixa do vídeo. Se o objeto filmado parecer esticado, ajuste
cellRatio para a largura de caractere da fonte escolhida dividida pela altura da linha. Para fundo
branco e texto preto, inverta chars e altere juntos o fundo e a cor de preenchimento do
renderizador.
Para contraste fraco, experimente adicionar eq=contrast=1.3 depois de setsar=1 no filtro de
extração e execute o script em um novo diretório. Isso é um ajuste visual, não uma garantia de
qualidade melhor. Mantenha a mesma taxa de extração e de reprodução: alterar apenas o -framerate
final muda a velocidade.
Diagnostique uma conversão com falha
A recusa diante de um diretório de saída existente é proposital, inclusive após uma execução com
falha. Inspecione os arquivos intermediários e ffmpeg.log e tente novamente com um novo nome de
diretório. Isso evita que textos ou imagens antigos estendam um clipe novo mais curto. Uma falha no
Lua interrompe o loop antes que quadros válidos posteriores possam ocultá-la, e uma falha de
codificação ou da decodificação final não deixa nenhum ascii_video.mp4 publicado.
As verificações detectam subprocessos com falha, dados RGB incompletos, falhas na escrita de texto e erros relatados nas etapas do FFmpeg do lote. Elas não provam que o vídeo de origem estava íntegro: um decodificador pode recuperar mídia danificada sem relatar erro. Inspecione conteúdo reconhecível perto do fim da entrada e da saída, assim como a contagem de quadros extraídos. Para a sequência amostrada, a duração do vídeo final é a contagem de quadros dividida por 10; ela não é um certificado de integridade da gravação original.
Cada quadro inicia ferramentas externas e grava arquivos intermediários, então esta abordagem é adequada para clipes criativos curtos, e não para reprodução em tempo real. Comece com uma grade legível e alguns segundos de filmagem; estenda o clipe depois que o primeiro e o último quadros mostrarem o efeito desejado.
