Créer de l’art ASCII à partir de vidéos avec Lua et FFmpeg
Utilisez Lua pour convertir la luminosité de chaque image d’une vidéo en texte, puis rendez ces caractères dans une vidéo d’art ASCII muette. L’exemple ci-dessous produit une grille de texte de 80 colonnes et un MP4 de 640 × 360 à 10 images par seconde, en utilisant FFmpeg pour le décodage et ImageMagick pour dessiner le texte.
Choisir une courte vidéo source
Commencez par une courte vidéo SDR intacte à pixels carrés. Les visages, les silhouettes et les grandes formes contrastées résistent mieux à la conversion que les petits caractères ou les arrière-plans chargés. Il s’agit d’un effet visuel échantillonné : il supprime l’audio, la couleur et les détails, et ne conserve pas toutes les images de la source.
La procédure s’exécute localement sous Linux, dans Bash. Elle enregistre les images PNG extraites, le texte ASCII, les PNG rendus et la vidéo finale ; utilisez donc un court extrait pendant que vous choisissez le rendu. Elle traite les images séquentiellement et s’arrête au premier échec détecté.
Vérifier les outils et créer un répertoire de travail
Il vous faut Bash, Lua, FFmpeg et ffprobe, la commande magick d’ImageMagick 7 ainsi qu’un
fichier de police à chasse fixe lisible. LuaRocks et les bibliothèques d’images pour Lua sont
inutiles. Les commandes présentées ici ont été testées avec Lua 5.5.1, FFmpeg/ffprobe 9.0.1,
ImageMagick 7.1.2-31 et Liberation Mono ; le programme Lua a également été rejoué avec Lua 5.4.8, et
la procédure avec FFmpeg/ffprobe 6.1.1. Ce sont les versions testées : cela ne signifie pas que
chaque version intermédiaire ait été testée, ni qu’un autre système d’exploitation l’ait été. Pour une nouvelle
installation de Lua, utilisez la version actuelle : la série Lua 5.4 est close avec sa version finale.
Installez ces outils via votre distribution Linux ou leurs téléchargements officiels. Votre version
compilée de FFmpeg doit inclure l’encodeur libx264, et ImageMagick doit prendre en charge le
PNG et le rendu de texte. Vérifiez les commandes avant de continuer :
lua -v && ffmpeg -version && ffprobe -version && magick -version
Créez un nouveau répertoire et n’y entrez que si la création réussit. Si ascii-demo existe
déjà, choisissez un autre nom ; ne supprimez pas un projet existant pour suivre ce tutoriel.
mkdir -- ascii-demo && cd -- ascii-demo
Copiez votre extrait dans ce répertoire sous le nom input.mp4. Si vous voulez un point de
départ reproductible, cette commande crée plutôt une mire de test de 1,3 seconde. Elle refuse de
remplacer un input.mp4 existant :
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
Extraire les images de la vidéo avec FFmpeg
Pour un aperçu d’une seule image, extrayez une nouvelle séquence. Les parenthèses limitent les
options du shell à ce bloc, et mkdir refuse de réutiliser un répertoire frames existant.
(
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
)
Le filtre fps de FFmpeg échantillonne à 10 FPS. scale=640:-2
conserve les proportions de l’entrée tout en choisissant une hauteur paire, et setsar=1 marque
les pixels obtenus comme carrés. Un extrait 16:9 produit des PNG de 640 × 360 nommés
frame_00000001.png, frame_00000002.png, etc. L’extrait de test fourni produit 13 images.
L’échantillonnage arrondit les horodatages à une grille d’images ; la durée d’une entrée quelconque
peut différer du résultat d’environ un intervalle d’image de 0,1 seconde.
Convertir un PNG en ASCII avec Lua
Enregistrez ce programme complet sous ascii.lua dans ascii-demo. Il analyse le PNG,
demande à FFmpeg une image RGB mise à l’échelle et vérifie que chaque pixel attendu est bien arrivé
avant de construire le texte.
-- 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
La fermeture du tube est importante : en Lua, file:close()
renvoie le statut du sous-processus pour un descripteur ouvert avec io.popen. Lire quelques
octets ne prouve pas à lui seul que FFmpeg a réussi. Le programme vérifie aussi l’écriture et la
fermeture de la sortie, afin qu’un répertoire manquant ou un disque plein ne puisse pas être signalé
comme un enregistrement réussi.
Prévisualisez la première image sur un terminal à fond sombre :
lua ascii.lua frames/frame_00000001.png
Pour l’enregistrer, créez d’abord le répertoire parent. Cette commande refuse de réutiliser
ascii_frames ; le programme Lua lui-même remplace un fichier texte de destination existant dès
que la conversion réussit et que l’écriture commence. Une écriture échouée peut laisser un fichier
texte partiel ; vérifiez donc le code de sortie du programme avant d’utiliser ce fichier.
mkdir -- ascii_frames && lua ascii.lua frames/frame_00000001.png ascii_frames/frame_00000001.txt
Pour une image 16:9, attendez-vous à 23 lignes de 80 caractères. Les pixels noirs deviennent des
espaces et les pixels blancs @, les luminosités intermédiaires correspondant aux
caractères situés entre les deux. Le calcul du nombre de lignes compense approximativement le fait
que les caractères sont plus hauts que larges ; ce n’est pas une métrique de police.
Créer une vidéo d’art ASCII
Enregistrez le script suivant sous create_ascii_video.sh, à côté de ascii.lua. Il extrait ses
propres images depuis la vidéo d’origine et n’utilise donc pas les répertoires d’aperçu. Indiquez-lui
un chemin d’entrée et un nouveau répertoire de sortie. Il refuse une destination existante, conserve
les fichiers intermédiaires pour inspection en cas d’échec, et ne publie ascii_video.mp4 qu’après
l’encodage, le comptage et le décodage de la vidéo obtenue.
Utilisez un répertoire de sortie local dont le chemin ne contient aucun caractère %.
FFmpeg interprète % dans les chemins de séquences d’images, même entre guillemets dans
le shell. Les espaces et les tirets initiaux sont pris en charge. Exécutez une seule instance à la
fois et ne modifiez pas ses fichiers d’entrée ou intermédiaires pendant son exécution.
#!/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"
Définissez FONT sur le chemin absolu d’une police à chasse fixe installée. La valeur par
défaut ci-dessus est le chemin Linux testé pour Liberation Mono ; d’autres distributions peuvent la
placer ailleurs. Vous pouvez trouver les polices installées avec les outils de gestion des polices de
votre système. Une police proportionnelle désalignera la grille.
FONT=/usr/share/fonts/liberation/LiberationMono-Regular.ttf \
bash create_ascii_video.sh input.mp4 ascii-run
Ouvrez ascii-run/ascii_video.mp4 dans votre lecteur vidéo. Pour la mire de test fournie, la sortie comporte
13 images, dure 1,3 seconde à 10 FPS et contient un seul flux vidéo H.264, sans audio. La
première et la dernière image rendues sont ascii-run/ascii_images/frame_00000001.png et
ascii-run/ascii_images/frame_00000013.png.
Le moteur de rendu label: d’ImageMagick dessine la grille
préformatée sans retour à la ligne automatique. Redimensionner le libellé avant d’ajouter les marges
permet aussi de garder les grilles en portrait sur le canevas. Si ImageMagick refuse de lire
@-, la politique de sécurité de votre installation bloque l’indirection de texte ;
utilisez une installation locale configurée en conséquence plutôt que de désactiver aveuglément sa
politique globale.
Ajuster l’apparence
Modifiez columns dans ascii.lua pour capturer plus ou moins de détails. Plus de
colonnes signifie des glyphes plus petits une fois ajustés au canevas vidéo fixe. Réglez
cellRatio sur la largeur de caractère de la police choisie divisée par sa hauteur de ligne si
le sujet paraît étiré. Pour un fond blanc et un texte noir, inversez chars et modifiez à
la fois l’arrière-plan et la couleur de remplissage du moteur de rendu.
Si le contraste est faible, essayez d’ajouter eq=contrast=1.3 après setsar=1 dans le filtre
d’extraction, puis exécutez le script vers un nouveau répertoire. Il s’agit d’un ajustement visuel,
pas d’une garantie de meilleure qualité. Conservez la même cadence d’extraction et de lecture :
modifier uniquement le -framerate final change la vitesse.
Diagnostiquer une conversion échouée
Un répertoire de sortie existant entraîne un refus délibéré, y compris après une exécution échouée.
Inspectez ses fichiers intermédiaires et ffmpeg.log, puis réessayez avec un nouveau nom de
répertoire. Cela évite que d’anciens textes ou d’anciennes images ne prolongent un nouvel extrait
plus court. Un échec de Lua arrête la boucle avant que des images valides ultérieures ne puissent le
masquer, et un échec d’encodage ou du décodage final ne laisse aucun ascii_video.mp4 publié.
Les vérifications détectent les sous-processus en échec, les données RGB incomplètes, les écritures de texte échouées et les erreurs signalées par les phases FFmpeg du traitement par lots. Elles ne prouvent pas que la vidéo source était intacte : un décodeur peut récupérer un média endommagé sans signaler d’erreur. Examinez un contenu reconnaissable vers la fin de votre entrée et de votre sortie, ainsi que le nombre d’images extraites. Pour la séquence échantillonnée, la durée de la vidéo finale est égale à son nombre d’images divisé par 10 ; ce n’est pas un certificat d’intégrité de l’enregistrement d’origine.
Chaque image lance des outils externes et écrit des fichiers intermédiaires ; cette approche convient donc à de courts extraits créatifs plutôt qu’à la lecture en temps réel. Commencez par une grille lisible et quelques secondes de vidéo ; allongez l’extrait une fois que la première et la dernière image montrent l’effet souhaité.
