Aus Linux-Magazin 07/2026

Videos aussortieren mit Go

© elnur / 123RF.com

Eine GUI-Applikation in Go hilft Mike Schilli dabei, Videos von seinem Handy über animierte Thumbnails auszuwählen und Ballast abzuwerfen.

Ich schreibe diese Kolumne nun seit fast 30 Jahren, und wenn man jeden Monat eine neue Applikation zusammenhämmert, kommt über einen derart langen Zeitraum schon einiges zusammen. Allerdings darf man nicht erwarten, dass einmal geschriebene Programme wartungsfrei bis zum Sankt-Nimmerleins-Tag laufen, besonders, wenn die genutzten Bibliotheken und APIs sich im Internettempo fortentwickeln.

Dennoch haben sich einige Ausgaben als Volltreffer entpuppt. So nutze ich heute noch den Fotosortierer Inuke aus dem Snapshot von November 2021 [1], um bei selbst geschossenen Handy-Fotos schnell die Spreu vom Weizen zu trennen. Allerdings funktioniert diese Applikation nur mit JPEG-Fotos. Aber ich nehme mit dem Smartphone oft auch Videos auf. Hier möchte ich den Schrott ebenfalls gleich aussortieren, bevor er bis in alle Ewigkeit Festplatten oder Cloudspeicher vollkleistert.

Schwarze Magie im Codec

Nun ist eine Videodatei keine simple Aneinanderreihung von Einzelbildern, und es stellt sich die Frage, wie ein Video-Viewer die Filmchen als Thumbnails so darstellen könnte, dass eine fundierte Auswahl gelingt.

Trivial ist das nicht, denn statt aneinandergereihter Einzelbilder enthält ein digitales Video von einem sogenannten Codec verarbeitete Daten. Sie definieren zwar den Einzelbildablauf, reduzieren aber mittels “schwarzer Magie” den dafür erforderlichen Speicherplatz drastisch. Manche dieser Codecs sind proprietär, aber Tools wie Ffmpeg schaffen es trotzdem, die Einzelbilddaten auszulesen. Wie dieses Kunststück gelingt, möchte man lieber nicht wissen!

Weil Freeze-Frames oft nicht sehr aussagekräftig sind, habe ich bei Youtube (Abbildung 1) den Trick abgekupfert, einen Trailer zum Video als animiertes GIF in einer Auswahlbox laufen zu lassen. Auf diese Weise lassen sich ein oder mehrere Videos durch Mausklicks auswählen. Das führt allerdings bei vielen Videos in der Auswahl zu Gewusel auf dem Bildschirm – selbst Youtube animiert immer nur ein oder zwei Videos als GIFs. Zur schnellen Auswahl einiger Dateien zur Weiterverarbeitung ist das Verfahren jedoch sehr effektiv.

Abbildung 1: Youtube animiert manche Videos in der Vorschau.

Abbildung 1: Youtube animiert manche Videos in der Vorschau.

Ruckelfrei im Hintergrund

Um aus einem voluminösen Video eine animierte GIF-Datei zu erzeugen, braucht selbst ein schneller Rechner einige Sekunden. Die Auswahl-GUI stellt darum alle per Pipe hereingereichten Videos erst einmal als graue Klötze dar (Abbildung 2), die sich nach und nach nahtlos in animierte GIFs verwandeln (Abbildung 3). Dabei reagiert die GUI weiter ruckelfrei auf Benutzereingaben, da der Render-Vorgang parallel im Hintergrund abläuft.

Abbildung 2: Die Auswahlbox stellt die Videos …

Abbildung 2: Die Auswahlbox stellt die Videos …

Abbildung 3: … nacheinander als animierte GIF-Dateien dar.

Abbildung 3: … nacheinander als animierte GIF-Dateien dar.

Auswahl und Rücknahme

Ein Klick mit der linken Maustaste auf ein Video-GIF – oder den grauen Klotz, falls der Renderer noch nicht fertig, aber der Dateiname aussagekräftig genug ist – markiert die zugehörige Filmdatei. Die GUI quittiert das mit einem dünnen blauen Rahmen um die Darstellung. Falls es sich um ein Versehen handelt, nimmt ein weiterer Mausklick auf das Video die Auswahl wieder zurück und das Rähmchen verschwindet. Ausgewählte Dateien spuckt das Tool am Ende des Programms als Pfade aus, damit eine weitere Pipe sie weiterverarbeiten kann – dazu später mehr.

Wer ein bestimmtes Video sofort in voller Auflösung im Player begutachten möchte, klickt mit der rechten Maustaste auf die Einzeldarstellung. Das wählt das Video nicht für den Grep-Filter aus, sondern startet von der Kommandozeile aus VLC, um das Filmchen anzuzeigen.

Stein auf Stein

Mit Go und dem Fyne-Framework lässt sich die GUI in wenigen Zeilen aufbauen. Als Erstes definiert Listing 1 die einzelnen Thumbnails in der an einen Kontaktabzug erinnernden Darstellung. Die Struktur »Tile« ab Zeile 12 definiert die beweglichen Bildchen. Der Eintrag »widget.BaseWidget« am Kopf der Struktur in Zeile 13 legt fest, dass sie das Verhalten von generischen Fyne-Widgets erbt. Sie erhält also beispielsweise Mausklicks seitens des Benutzers als Events und kann sie in den Funktionen »Tapped()« (linke Maustaste, ab Zeile 46) und »TappedSecondary()« (rechte Taste, ab Zeile 57) verarbeiten.

Der Konstruktor ab Zeile 21 nimmt den Namen der darzustellenden Videodatei als einzigen Parameter entgegen und setzt den Text im »Label«-Widget »label« entsprechend. Zunächst zeigt die Kachel nur das in Zeile 28 definierte graue Rechteck, das später als erster Parameter in den vertikal stapelnden »VBox«-Container wandert. Zunächst erscheinen die Widgets als nicht ausgewählt, also ohne blauen Rahmen, wozu Zeile 26 die Rahmenfarbe auf Weiß setzt. So ist der Rahmen zwar da, bleibt aber für den Benutzer vorerst unsichtbar. “Smoke and Mirrors” nennen US-Amerikaner diese Zaubertricktaktik.

Später ruft das Hauptprogramm die Funktion »Update()« ab Zeile 64 auf, die das graue Rechteck durch ein animiertes GIF-Bild ersetzt. Ob das Widget ausgewählt wurde, gibt dessen Attribut »Selected« an. Kommt vom Benutzer ein linker Mausklick, springt »Tapped()« ab Zeile 46 an und frischt das Feld »Selected« sowie die Rahmenfarbe auf. »Refresh()« in Zeile 55 gibt dem Benutzer dazu optisches Feedback.

Listing 1

tile.go

package main
import (
  "image/color"
  "os/exec"
  "path/filepath"
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/canvas"
  "fyne.io/fyne/v2/container"
  "fyne.io/fyne/v2/widget"
  xwidget "fyne.io/x/fyne/widget"
)
type Tile struct {
  widget.BaseWidget
  Path string
  content *fyne.Container
  border  *canvas.Rectangle
  vbox    *fyne.Container
  Selected bool
}
var PeepSize = fyne.NewSize(100, 100)
func NewTile(path string) *Tile {
  t := &Tile{
    Path: path,
  }
  t.border = canvas.NewRectangle(color.Transparent)
  t.border.StrokeColor = color.White
  t.border.StrokeWidth = 2
  rect := canvas.NewRectangle(color.Gray{Y: 128})
  rect.SetMinSize(PeepSize)
  label := widget.NewLabel(filepath.Base(path))
  label.Alignment = fyne.TextAlignCenter
  t.vbox = container.NewVBox(
    rect,
    label,
  )
  t.content = container.NewMax(
    t.border,
    container.NewPadded(t.vbox),
  )
  t.ExtendBaseWidget(t)
  return t
}
func (t *Tile) CreateRenderer() fyne.WidgetRenderer {
  return widget.NewSimpleRenderer(t.content)
}
func (t *Tile) Tapped(*fyne.PointEvent) {
  if t.Selected {
    t.Selected = false
    t.border.StrokeColor = color.White
  } else {
    t.Selected = true
    blue := color.RGBA{0, 0, 255, 255}
    t.border.StrokeColor = blue
  }
  t.border.Refresh()
}
func (t *Tile) TappedSecondary(*fyne.PointEvent) {
  cmd := exec.Command("/bin/sh", "vlc", t.Path)
  err := cmd.Start()
  if err != nil {
    panic(err)
  }
}
func (t *Tile) Update(gif *xwidget.AnimatedGif) {
  fyne.Do(func() {
    gif.SetMinSize(PeepSize)
    t.vbox.Objects[0] = gif
    t.vbox.Refresh()
  })
}

Wurst wird gemacht

Wie entsteht nun aus einer Videodatei ein animiertes GIF? Dazu wirft in Listing 2 die Go-Funktion »generateGif()« ab Zeile 38 das externe Tool Ffmpeg an. Es wurde vorher mit »sudo apt install ffmpeg« installiert. Mit dem Parameter »-ss 10« in Zeile 42 gibt der Aufruf an, dass Ffmpeg erst 10 Sekunden in das Video hineinfahren und dann die nächsten drei Sekunden (»-t 3«) des Films extrahieren soll.

Die Optionen »fps=10,scale=200:-1:flags=lanczos« in Zeile 44 legen fest, dass das entstehende GIF mit 10 Frames pro Sekunde läuft und 200 Pixel breit ist. Später zeigt die GUI das Video nur 100 Pixel breit an, aber das Oversampling verbessert die Qualität der Darstellung und vermindert die sonst auftretenden Artefakte. Die Höhe ermittelt das Tool wegen des Werts »-1« anhand der Bilddimensionen. Der Parameter »lanczos« sorgt für qualitativ hochwertiges Sampling nach der Lanczos-Methode.

Listing 2

gif.go

package main
import (
  "bytes"
  "fmt"
  "log"
  "os"
  "os/exec"
  "path/filepath"
  "sync"
  "fyne.io/fyne/v2/storage"
  xwidget "fyne.io/x/fyne/widget"
)
func updateGifs(tiles []*Tile) {
  var mu sync.Mutex
  sem := make(chan struct{}, 4)
  for i, tile := range tiles {
    i, tile := i, tile // shadow
    go func() {
      sem <- struct{}{}
      defer func() { <-sem }()
      tmp := filepath.Join(os.TempDir(), fmt.Sprintf("preview_%d.gif", i))
      if err := generateGif(tile.Path, tmp); err != nil {
        log.Println(err)
        return
      }
      g, err := xwidget.NewAnimatedGif(storage.NewFileURI(tmp))
      if err != nil {
        log.Println(err)
        return
      }
      g.Start()
      mu.Lock()
      tile.Update(g)
      mu.Unlock()
    }()
  }
}
func generateGif(in, out string) error {
  cmd := exec.Command("ffmpeg",
    "-y",
    "-i", in,
    "-ss", "10",
    "-t", "3",
    "-vf", "fps=10,scale=200:-1:flags=lanczos",
    out,
  )
  var stderr bytes.Buffer
  cmd.Stderr = &stderr
  err := cmd.Run()
  if err != nil {
    return fmt.Errorf("%v: %s", err, stderr.String())
  }
  return nil
}

Weil das alles Zeit braucht und den Rechner beansprucht, fährt »updateGifs()« ab Zeile 13 in Listing 2 die Produktion mit bis zu vier Go-Routinen parallel. Der Channel »sem« mit einem vier Einträge großen Puffer synchronisiert den Ablauf. Dabei schiebt Zeile 19 eine leere Struktur in den Channel hinein, der blockt, falls bereits vier Einträge dort stehen.

Die von Ffmpeg unter einem temporären Namen erzeugte GIF-Datei erwacht mittels des Pakets fyne.io/x/fyne/widget und »NewAnimatedGif()« beziehungsweise »Start()« ab Zeile 31 im Fyne-Framework zum Leben. Die in Zeile 33 auf die »Tile«-Struktur von Listing 1 aufgerufene Funktion »Update()« bringt das Daumenkino auf den Schirm.

Von Haus aus implementiert Go bei parallel laufenden Go-Routinen keine Schutzmechanismen gegen Datenkorruption. Daher sorgen »Lock()« und »Unlock()« eines Mutex-Semaphors aus dem Paket sync dafür, dass sich parallel laufende Go-Routinen in Zeile 33 nicht in die Quere kommen. So kann »Update()« in aller Ruhe die mehrere Schritte lange Auffrischung vornehmen.

Schattenvariablen

Als aufmerksamer Leser wundern Sie sich vielleicht, warum Zeile 17 die Schleifenvariablen »i« und »tile« dupliziert. Die Antwort ist verzwickt: Falls jede Schleifeniteration eine nebenläufige Go-Routine abfeuert, weiß am Ende keiner mehr, welchen Wert die Schleifenvariable gerade hat. Falls die For-Schleife den Index »i« für den nächsten Durchgang zum Beispiel gerade um eins erhöht, ist die Go-Routine zur GIF-Erzeugung vielleicht noch gar nicht am Ende angelangt. Dann vergäbe sie auf einmal »i+1« statt »i« als laufende Nummer der GIF-Datei. Das Verfahren, Schleifenvariablen lokalen Variablen zuzuweisen, nennt man Shadowing. Es kommt in Go oft bei For-Schleifen mit Go-Routinen zum Einsatz.

Das Hauptprogramm in Listing 3 bringt die GUI auf den Schirm und startet den GIF-Reigen. Hat der Benutzer die GUI zusammengefaltet, gibt es abschließend die vom ihm ausgewählten Videos auf der Standardausgabe aus.

Listing 3

vidgrep.go

package main
import (
  "bufio"
  "fmt"
  "os"
  "path/filepath"
  "strings"
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/app"
  "fyne.io/fyne/v2/container"
  "fyne.io/fyne/v2/widget"
)
func readInput() []*Tile {
  var tiles []*Tile
  sc := bufio.NewScanner(os.Stdin)
  for sc.Scan() {
    p := sc.Text()
    if strings.ToLower(filepath.Ext(p)) == ".mov" {
      tiles = append(tiles, NewTile(p))
    }
  }
  return tiles
}
func main() {
  tiles := readInput()
  a := app.New()
  w := a.NewWindow("Select clips")
  cols := 4
  grid := container.NewGridWithColumns(cols)
  scroll := container.NewScroll(grid)
  for _, tile := range tiles {
    grid.Add(tile)
  }
  go updateGifs(tiles)
  done := widget.NewButton("Done", func() {
    w.Close()
  })
  w.SetContent(container.NewBorder(nil, done, nil, nil, scroll))
  w.Resize(fyne.NewSize(800, 600))
  w.Canvas().SetOnTypedKey(
    func(ev *fyne.KeyEvent) {
      key := string(ev.Name)
      switch key {
      case "Q":
        w.Close()
      }
    })
  w.ShowAndRun()
  for _, tile := range tiles {
    if tile.Selected {
      fmt.Println(tile.Path)
    }
  }
}

Die Guten ins Töpfchen

Listing 3 liest mit »readInput()« ab Zeile 13 die zeilenweise über die Standardeingabe eintrudelnden Dateinamen der Videos ein. Zu jedem legt die Applikation mittels des Konstruktors »NewTile()« aus Listing 1 ein neues Guckloch für das Daumenkino an und schiebt es ans Ende des Arrays »tiles«, das die Funktion nach getaner Arbeit ans Hauptprogramm zurückreicht. Letzteres firmiert wie üblich als »main« (ab Zeile 24) und erzeugt kurz nach Programmstart über das Fyne-Framework ein neues App-Fenster.

Die For-Schleife ab Zeile 31 iteriert über alle vorher von »readInput« angelegten Tiles und hängt sie mit »Add()« in die Kachelmatrix »grid« ein. Das App-Layout ist typisch: Im Zentrum steht die Videomatrix, unten der Knopf für die Bestätigung zum Programmabbruch. Dabei klebt der Button am unteren Ende und wächst beim Aufziehen des Fensters nicht in die Höhe. Das Grid-Widget hingegen braucht den gesamten restlichen Platz auf, denn die Bildreihen könnten theoretisch ins Unermessliche wachsen.

Mit den Aufgaben wachsen

Damit der Videoselektor (fast) beliebig viele Videos darstellen kann, selbst wenn der Platz im Applikationsfenster dazu nicht ausreicht, liegt das Grid-Widget in einem Scroll-Widget (Zeile 30). Das zeigt entsprechend der vom Benutzer eingestellten Größe des Anwendungsfensters einen Ausschnitt an, der sich mit der Maus verschieben lässt (Abbildung 4).

Abbildung 4: Bei &Uuml;berf&uuml;llung springt das Scroll-Widget ein.

Abbildung 4: Bei Überfüllung springt das Scroll-Widget ein.

Dieses Layout mit einer Info-Tafel im Zentrum und einem Button am unteren Ende ist für Apps so typisch, dass Fyne hierfür das sogenannte Border-Layout anbietet (Abbildung 5). Neben “Center” und “Bottom”, wie im vorliegenden Fall, darf im generischen Border-Layout oben noch eine Kopfzeile stehen, links und rechts bleibt Platz für Rand-Widgets. Die fehlen im vorliegenden Fall, und Zeile 38 setzt deshalb die nicht besetzten Parameter des Border-Layouts jeweils auf »nil«.

Abbildung 5: Das Border-Layout in Fyne.

Abbildung 5: Das Border-Layout in Fyne.

Fehlt nur noch, das Applikationsfenster mit »Resize()« aufzuziehen (Zeile 39). Das ist wichtig, da Fyne es sonst minimalisiert und man sich die Haare rauft, weil es scheinbar nicht hochkommt. Die ab Zeile 48 mit »ShowAndRun()« startende primäre Event-Schleife malt die GUI und reagiert auf Input, bis der Benutzer das Kommando zum Zusammenfalten gibt.

Die Applikation reagiert nicht nur auf Mausklicks auf Done am unteren Ende des Fensters, sondern auch auf Tastatureingaben. So führt ein Druck auf [Q] genau wie die Aktivierung der Schaltfläche zum Schließen des Fensters. Die Funktion »SetOnTypedKey()« des Canvas-Objekts in Zeile 40 definiert die dem Tastendruck zugeordnete Aktion.

Nach erfolgter Auswahl faltet »w.Close()« entweder in Zeile 36 (Klick auf die Schaltfläche) oder in Zeile 45 (Tastendruck) die GUI zusammen, und »ShowAndRun()« (Zeile 48) beendet sich. Vor dem endgültigen Programmabbruch gilt es, noch durch alle definierten Tile-Strukturen zu iterieren und diejenigen mit gesetztem »Selected«-Flag (Abbildung 6) auf der Standardausgabe auszugeben (Abbildung 7).

Abbildung 6: Mit der Maus w&auml;hlt der Benutzer Videos aus &hellip;

Abbildung 6: Mit der Maus wählt der Benutzer Videos aus …

Abbildung 7: &hellip; die zur Weiterverarbeitung auf der Standardausgabe erscheinen.

Abbildung 7: … die zur Weiterverarbeitung auf der Standardausgabe erscheinen.

Wie am Fließband

Der Go-typische Dreisprung aus Listing 4 holt die Abhängigkeiten der Sourcen von Github ab und kompiliert das Ganze zu einem Binary. Unter Linux bindet sich Fyne über einen C-Wrapper aus Go an die Bibliotheken libx11-dev, libgl1-mesa-dev, libxcursor-dev und xorg-dev an. Die installieren Sie beispielsweise unter Ubuntu per »sudo apt-get install«, damit das anschließende »go build« der Fyne-Anwendung auf das erforderliche Fundament zurückgreifen kann.

Listing 4

build.sh

$ go mod init vidgrep
$ go mod tidy
$ go build vidgrep.go gif.go tile.go
$ ls *.mov | ./vidgrep | xargs -I{} cp {} ~/Desktop

Da das Tool wie ein Grep-Filter arbeitet, lässt es sich vielfältig in Unix-Pipelines einsetzen. Sie möchten alle angeklickten Videos löschen? Stellen Sie mit »| xargs rm« einfach eine Pipe dahinter. Wollen Sie alle angeklickten Videodateien mit dem VLC-Player ansehen, hängen Sie stattdessen »| xargs -n 1 vlc –play-and-exit« an. So ruft Xargs den Player immer nur mit einer Datei aus der Pipe auf. Erst dann kommt das nächste Video zum Zug.

Die letzte Zeile in Listing 4 zeigt noch, wie Xargs alle per GUI ausgewählten Videos auf den Desktop kopiert. Das demonstriert einmal mehr eindrucksvoll, dass das mächtige Pipe-Konzept der Unix-Shell auch mehr als 50 Jahre nach seiner Entstehung ungeschlagen ist. (uba/jlu)

Infos

  1. Snapshot: Mike Schilli, “Die Schlechten ins Kröpfchen”, LM 11/2021, S. 86, https://www.lm-online.de/44800
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben