ASCII-Kunst aus Videos mit Lua und FFmpeg erstellen
Ordnen Sie mit Lua der Helligkeit jedes Videoframes Textzeichen zu und rendern Sie daraus ein stummes ASCII-Kunst-Video. Das folgende Beispiel erzeugt ein Textraster mit 80 Spalten und ein MP4 mit 640 × 360 bei 10 Bildern pro Sekunde. FFmpeg decodiert das Video, ImageMagick zeichnet den Text.
Ein kurzes Eingabevideo wählen
Beginnen Sie mit einem kurzen, intakten SDR-Video mit quadratischen Pixeln. Gesichter, Silhouetten und große, kontrastreiche Formen bleiben bei der Umwandlung besser erkennbar als kleine Schrift oder unruhige Hintergründe. Dieser visuelle Effekt basiert auf einer Abtastung: Audio, Farbe und Details entfallen, und nicht jeder Quellframe bleibt erhalten.
Der Workflow läuft lokal unter Linux in Bash. Er speichert PNG-Frames, ASCII-Text, gerenderte PNGs und das fertige Video. Verwenden Sie daher einen kurzen Clip, während Sie das Aussehen abstimmen. Die Frames werden nacheinander verarbeitet; beim ersten erkannten Fehler stoppt der Ablauf.
Werkzeuge prüfen und ein Arbeitsverzeichnis erstellen
Sie benötigen Bash, Lua, FFmpeg und ffprobe, den Befehl magick aus ImageMagick 7
sowie eine lesbare Datei mit einer nichtproportionalen Schrift. LuaRocks und Lua-Bildbibliotheken sind
nicht erforderlich. Die Befehle wurden mit Lua 5.5.1, FFmpeg/ffprobe 9.0.1, ImageMagick 7.1.2-31 und
Liberation Mono getestet. Das Lua-Programm wurde außerdem mit Lua 5.4.8 erneut ausgeführt, der Workflow
mit FFmpeg/ffprobe 6.1.1. Dies sind getestete Versionen, keine Aussage darüber, dass jede
Zwischenversion oder ein anderes Betriebssystem getestet wurde. Verwenden Sie bei einer neuen
Lua-Installation die aktuelle Version:
Lua 5.4 hat seine letzte Version erreicht.
Installieren Sie diese Werkzeuge über Ihre Linux-Distribution oder die offiziellen Downloads. Ihr
FFmpeg-Build muss den Encoder libx264 enthalten, und ImageMagick muss PNG sowie
Textrendering unterstützen. Prüfen Sie die Befehle, bevor Sie fortfahren:
lua -v && ffmpeg -version && ffprobe -version && magick -version
Erstellen Sie ein neues Verzeichnis und wechseln Sie nur hinein, wenn das Erstellen erfolgreich war.
Falls ascii-demo bereits existiert, wählen Sie einen anderen Namen. Löschen Sie kein
bestehendes Projekt, um dieser Anleitung zu folgen.
mkdir -- ascii-demo && cd -- ascii-demo
Kopieren Sie Ihren Clip als input.mp4 in dieses Verzeichnis. Für einen
reproduzierbaren Ausgangspunkt erzeugt dieser Befehl stattdessen ein 1,3 Sekunden langes Testmuster.
Er verweigert das Ersetzen einer vorhandenen Datei input.mp4:
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
Videoframes mit FFmpeg extrahieren
Extrahieren Sie für die Vorschau eines einzelnen Frames eine neue Sequenz. Die Klammern begrenzen die
Shell-Optionen auf diesen Block, und mkdir verweigert die Wiederverwendung eines
vorhandenen Verzeichnisses frames.
(
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
)
Der FFmpeg-Filter fps tastet mit 10 FPS ab.
scale=640:-2 erhält die Proportionen der Eingabe und wählt dabei eine gerade Höhe;
setsar=1 kennzeichnet die resultierenden Pixel als quadratisch. Ein 16:9-Clip
erzeugt PNGs mit 640 × 360 und Namen wie frame_00000001.png,
frame_00000002.png und so weiter. Der mitgelieferte Testclip erzeugt 13 Frames. Bei der
Abtastung werden Zeitstempel auf ein Frame-Raster gerundet. Bei beliebigen Eingabelängen kann die
Dauer des Ergebnisses um ungefähr ein Frame-Intervall von 0,1 Sekunden abweichen.
Ein PNG mit Lua in ASCII umwandeln
Speichern Sie dieses vollständige Programm als ascii.lua in
ascii-demo. Es analysiert das PNG, fordert von FFmpeg einen skalierten RGB-Frame an
und prüft vor dem Erstellen des Textes, ob alle erwarteten Pixel angekommen sind.
-- 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
Das Schließen der Pipe ist wichtig: file:close() in Lua
liefert bei einem mit io.popen geöffneten Handle den Status des Unterprozesses
zurück. Einige gelesene Bytes allein belegen nicht, dass FFmpeg erfolgreich war. Das Programm prüft
auch das Schreiben und Schließen der Ausgabe. So kann ein fehlendes Verzeichnis oder ein voller
Datenträger nicht als erfolgreiches Speichern gemeldet werden.
Zeigen Sie den ersten Frame zur Vorschau auf einem dunklen Terminalhintergrund an:
lua ascii.lua frames/frame_00000001.png
Erstellen Sie zum Speichern zuerst das übergeordnete Verzeichnis. Dieser Befehl verweigert die
Wiederverwendung von ascii_frames. Das Lua-Programm selbst ersetzt eine vorhandene
Textzieldatei, sobald die Umwandlung erfolgreich war und das Schreiben beginnt. Ein fehlgeschlagener
Schreibvorgang kann eine unvollständige Textdatei hinterlassen. Prüfen Sie daher den Exit-Status,
bevor Sie die Datei weiterverwenden.
mkdir -- ascii_frames && lua ascii.lua frames/frame_00000001.png ascii_frames/frame_00000001.txt
Bei einem 16:9-Frame sind 23 Zeilen mit jeweils 80 Zeichen zu erwarten. Schwarze Pixel werden
Leerzeichen zugeordnet, weiße Pixel @; dazwischenliegende Helligkeiten
werden den Zeichen dazwischen zugeordnet. Die Zeilenberechnung gleicht näherungsweise aus, dass
Zeichen höher als breit sind. Sie ist keine Schriftmetrik.
Ein ASCII-Kunst-Video erstellen
Speichern Sie Folgendes als create_ascii_video.sh neben ascii.lua. Das
Skript extrahiert eigene neue Frames aus dem Originalvideo und verwendet daher die
Vorschauverzeichnisse nicht. Übergeben Sie ihm einen Eingabepfad und ein neues Ausgabeverzeichnis.
Es verweigert ein vorhandenes Ziel, bewahrt Zwischendateien zur Untersuchung nach einem Fehler auf
und stellt ascii_video.mp4 erst bereit, nachdem das resultierende Video codiert,
seine Frames gezählt und das Video decodiert wurde.
Verwenden Sie ein lokales Ausgabeverzeichnis, dessen Pfad kein Zeichen
% enthält. FFmpeg interpretiert % in Pfaden
für Bildsequenzen auch innerhalb von Shell-Anführungszeichen. Leerzeichen und führende Bindestriche
werden unterstützt. Führen Sie jeweils nur eine Instanz aus und ändern Sie während der Ausführung
weder die Eingabe- noch die Zwischendateien.
#!/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"
Setzen Sie FONT auf den absoluten Pfad einer installierten nichtproportionalen
Schrift. Der Standardwert oben ist der getestete Linux-Pfad für Liberation Mono; andere Distributionen
können die Schrift an einem anderen Ort ablegen. Mit den Schriftwerkzeugen Ihres Systems finden Sie
installierte Schriften. Eine proportionale Schrift richtet das Raster nicht korrekt aus.
FONT=/usr/share/fonts/liberation/LiberationMono-Regular.ttf \
bash create_ascii_video.sh input.mp4 ascii-run
Öffnen Sie ascii-run/ascii_video.mp4 in Ihrem Videoplayer. Beim mitgelieferten Testmuster hat die
Ausgabe 13 Frames, läuft 1,3 Sekunden bei 10 FPS und enthält einen H.264-Videostream ohne Audio.
Der erste und der letzte gerenderte Frame sind ascii-run/ascii_images/frame_00000001.png und
ascii-run/ascii_images/frame_00000013.png.
Der Renderer label: von ImageMagick zeichnet das
vorformatierte Raster ohne Zeilenumbruch. Die Größenänderung des Labels vor dem Auffüllen sorgt
dafür, dass auch Hochformatraster auf der Zeichenfläche bleiben. Wenn ImageMagick das Lesen von
@- verweigert, blockiert die Sicherheitsrichtlinie Ihrer Installation das
indirekte Einlesen von Text. Nutzen Sie eine passend konfigurierte lokale Installation, statt deren
globale Richtlinie blind zu deaktivieren.
Das Aussehen abstimmen
Ändern Sie columns in ascii.lua, um mehr oder weniger Details
zu erfassen. Mehr Spalten bedeuten kleinere Glyphen, wenn sie in die feste Videozeichenfläche
eingepasst werden. Setzen Sie cellRatio auf die Zeichenbreite der gewählten Schrift
geteilt durch ihre Zeilenhöhe, falls das Motiv gestreckt wirkt. Für einen weißen Hintergrund mit
schwarzem Text kehren Sie chars um und ändern Sie Hintergrund und Füllfarbe
des Renderers gemeinsam.
Versuchen Sie bei schwachem Kontrast, eq=contrast=1.3 nach
setsar=1 in den Extraktionsfilter einzufügen. Führen Sie das Skript dann mit einem
neuen Zielverzeichnis aus. Dies ist eine visuelle Anpassung, keine Garantie für bessere Qualität.
Behalten Sie dieselbe Extraktions- und Wiedergaberate bei: Wenn Sie nur den abschließenden Wert
-framerate ändern, ändert sich die Geschwindigkeit.
Eine fehlgeschlagene Umwandlung diagnostizieren
Ein vorhandenes Ausgabeverzeichnis führt bewusst zur Ablehnung, auch nach einem fehlgeschlagenen
Durchlauf. Untersuchen Sie die Zwischendateien und ffmpeg.log darin und versuchen
Sie es dann mit einem neuen Verzeichnisnamen erneut. Das verhindert, dass alter Text oder alte Bilder
einen kürzeren neuen Clip verlängern. Ein Lua-Fehler stoppt die Schleife, bevor spätere gültige Frames
ihn verdecken können. Bei einem Fehler beim Encoding oder beim abschließenden Decodieren wird keine
Datei ascii_video.mp4 bereitgestellt.
Die Prüfungen erkennen fehlgeschlagene Unterprozesse, unvollständige RGB-Daten, fehlgeschlagene Textschreibvorgänge und gemeldete Fehler in den FFmpeg-Phasen der Stapelverarbeitung. Sie belegen nicht, dass das Quellvideo intakt war: Ein Decoder kann beschädigte Medien wiederherstellen, ohne einen Fehler zu melden. Prüfen Sie erkennbare Inhalte nahe dem Ende Ihrer Eingabe und Ausgabe sowie die Anzahl extrahierter Frames. Für die abgetastete Sequenz entspricht die endgültige Videodauer der Anzahl ihrer Frames geteilt durch 10. Das ist kein Integritätsnachweis für die Originalaufnahme.
Jeder Frame startet externe Werkzeuge und schreibt Zwischendateien. Dieser Ansatz eignet sich daher für kurze kreative Clips statt für die Echtzeitwiedergabe. Beginnen Sie mit einem lesbaren Raster und wenigen Sekunden Videomaterial. Verlängern Sie den Clip, sobald der erste und letzte Frame den gewünschten Effekt zeigen.
