Peepshow (Linux-Magazin, Juli 2026)

Eine GUI-Applikation in Go hilft Mike Schilli dabei, Videos von seinem Handy mittels animierter Thumbnails auszuwählen.

Diese Kolumne läuft ja nun bereits seit fast 30 Jahren, und wenn man jeden Monat eine neue Applikation zusammenhämmert, kommt über einen derart langen Zeitraum schon Etliches zusammen. Natürlich kann man nicht erwarten, dass einmal geschriebene Programme wartungsfrei ewig laufen, besonders wenn genutzte Libraries und APIs sich im Internettempo fortentwickeln.

Aber einige Ausgaben haben sich als Volltreffer entpuppt, so nutze ich auch heute noch den Fotosortierer inuke des November-Snapshots von 2021 ([2]), um bei selbstgeschossenen Handy-Fotos schnell die Spreu vom Weizen zu trennen. Nun funktioniert diese Applikation aber nur mit JPG-Fotos, während das Handy oft auch Videos aufnimmt, bei denen die schrottigen ebenfalls aussortiert gehören bevor sie bis in alle Ewigkeit Festplatten oder Cloudspeicher vollkleistern.

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 darstellen könnte, sodass eine informierte Auswahl möglich ist.

Abbildung 1: Youtube animiert manche Videos in der Vorschau.

Ganz einfach ist das nicht, denn statt aneinandergereiter Einzelbilder enthält eine digitale Videodatei die Daten in einem sogenannten Codec, das zwar den Einzelbildablauf definiert, aber mittels Hilfe schwarzer Magie den dafür erforderlichen Speicherplatz drastisch reduziert. Manche dieser Codecs sind proprietär, aber Tools wie ffmpeg schaffen es trotzdem, die Einzelbilddaten auszulesen. Wie diese Wurst hergestellt wurde, möchte man lieber nicht wissen!

Abbildung 2: Die Auswahlbox stellt die Videos ...

Weil Freeze-Frames oft nicht sehr aussagekräftig sind, habe ich mir von Youtube (Abbildung 1) den Trick abgeguckt, einen Trailer zum Video als animiertes Gif in einer Auswahlbox laufen zu lassen. So kann der User ein oder mehrere Videos durch Mausklicks auswählen. Das führt zwar bei vielen Videos in der Auswahl zu Gewusel auf dem Bildschirm, sodass selbst Youtube immer nur ein oder zwei Videos als Gifs animiert, aber zur schnellen Selektion einiger Dateien zur Weiterverarbeitung ist das Verfahren sehr effektiv.

Abbildung 3: ... nacheinander als animierte Gif-Dateien dar.

Ruckelfrei im Hintergrund

Aus einem womöglich gigabytegroßen Video eine animierte Gif-Datei zu erzeugen, dafür braucht selbst ein schneller Rechner schon ein paar Sekunden. Die Auswahl-GUI stellt darum alle per Pipe hereingereichten Videos als graue Klötze dar (Abbildung 2), die nach und nach nahtlos durch fertige animierte Gifs erzetzt werden (Abbildung 3). Dabei reagiert die GUI weiterhin ruckelfrei auf Usereingaben, da der Rendervorgang parallel im Hintergrund abläuft.

Auswahl und Rücknahme

Ein Klick mit der linken Maustaste auf ein Video-Gif (oder auch auf den grauen Klotz, falls der Renderer noch nicht fertig aber der Dateiname aussagekräftig genug ist) markiert die zugehörige Filmdatei und die GUI reflektiert dies 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 gleich in voller Auflösung im Player begutachten möchte, klickt mit der rechten Maustaste auf die Einzeldarstellung. Dies wählt das Video nicht für den Grep-Filter aus, sondern feuert vlc von der Kommandozeile ab, 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 kontaktabzugartigen Darstellung. Jedes der beweglichen Bildchen wird von der Struktur Tile ab Zeile 12 definiert. Der Eintrag widget.BaseWidget am Kopf der Struktur in Zeile 13 legt fest, dass sie das Verhalten von generischen Fyne-Widgets erbt, also zum Beispiel Mausklicks seitens des Users als Events erhält und in den Funktionen Tapped() (linke Taste, ab Zeile 46) und TappedSecondary() (rechte Taste, ab Zeile 57) verarbeiten kann.

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 ein graues Rechteck, das Zeile 28 definiert und das später als erster Parameter in den vertikal stapelnden VBox-Container wandert. Zunächst erscheinen Widgets naturgemäß unselektiert, zeigen also keinen blauen Rahmen, und deshalb setzt Zeile 26 die Rahmenfarbe auf weiß. So ist der Rahmen zwar da, aber für den User noch unsichtbar. Smoke and Mirrors, wie der Amerikaner sagt.

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

Listing 1: tile.go

    01 package main
    02 import (
    03   "image/color"
    04   "os/exec"
    05   "path/filepath"
    06   "fyne.io/fyne/v2"
    07   "fyne.io/fyne/v2/canvas"
    08   "fyne.io/fyne/v2/container"
    09   "fyne.io/fyne/v2/widget"
    10   xwidget "fyne.io/x/fyne/widget"
    11 )
    12 type Tile struct {
    13   widget.BaseWidget
    14   Path string
    15   content *fyne.Container
    16   border  *canvas.Rectangle
    17   vbox    *fyne.Container
    18   Selected bool
    19 }
    20 var PeepSize = fyne.NewSize(100, 100)
    21 func NewTile(path string) *Tile {
    22   t := &Tile{
    23     Path: path,
    24   }
    25   t.border = canvas.NewRectangle(color.Transparent)
    26   t.border.StrokeColor = color.White
    27   t.border.StrokeWidth = 2
    28   rect := canvas.NewRectangle(color.Gray{Y: 128})
    29   rect.SetMinSize(PeepSize)
    30   label := widget.NewLabel(filepath.Base(path))
    31   label.Alignment = fyne.TextAlignCenter
    32   t.vbox = container.NewVBox(
    33     rect,
    34     label,
    35   )
    36   t.content = container.NewMax(
    37     t.border,
    38     container.NewPadded(t.vbox),
    39   )
    40   t.ExtendBaseWidget(t)
    41   return t
    42 }
    43 func (t *Tile) CreateRenderer() fyne.WidgetRenderer {
    44   return widget.NewSimpleRenderer(t.content)
    45 }
    46 func (t *Tile) Tapped(*fyne.PointEvent) {
    47   if t.Selected {
    48     t.Selected = false
    49     t.border.StrokeColor = color.White
    50   } else {
    51     t.Selected = true
    52     blue := color.RGBA{0, 0, 255, 255}
    53     t.border.StrokeColor = blue
    54   }
    55   t.border.Refresh()
    56 }
    57 func (t *Tile) TappedSecondary(*fyne.PointEvent) {
    58   cmd := exec.Command("/bin/sh", "vlc", t.Path)
    59   err := cmd.Start()
    60   if err != nil {
    61     panic(err)
    62   }
    63 }
    64 func (t *Tile) Update(gif *xwidget.AnimatedGif) {
    65   fyne.Do(func() {
    66     gif.SetMinSize(PeepSize)
    67     t.vbox.Objects[0] = gif
    68     t.vbox.Refresh()
    69   })
    70 }

Wurst wird gemacht

Wie entsteht nun aus einer Videodatei ein animiertes Gif? Dazu wirft die Funktion generateGif() ab Zeile 38 in Listing 2 aus Go das externe Tool ffmpeg an. Es wurde vorher mit sudo apt install ffmpeg installiert. Mit dem Parameter -ss 10 gibt der Aufruf an, dass ffmpeg erst 10 Sekunden in das Video hineinfahren soll, und dann wegen -t 3 die nächsten drei Sekunden des Films extrahiert. Die Optionen fps=10,scale=200:-1:flags=lanczos definieren noch, dass das entstehende Gif mit 10 Frames pro Sekunde läuft und 200 Pixels breit ist. Später wird das Video in der GUI nur 100 Pixels breit dargestellt werden, aber dieses Oversampling verbessert die Qualität der Darstellung und vermindert die sonst auftretenden Artefakte. Die Höhe ermittelt das Tool wegen dem Wert -1 aufgrund der Bilddimension und lanzos setzt qualitativ hochwertiges Sampling nach der Lanzos-Methode.

Listing 2: gif.go

    01 package main
    02 import (
    03   "bytes"
    04   "fmt"
    05   "log"
    06   "os"
    07   "os/exec"
    08   "path/filepath"
    09   "sync"
    10   "fyne.io/fyne/v2/storage"
    11   xwidget "fyne.io/x/fyne/widget"
    12 )
    13 func updateGifs(tiles []*Tile) {
    14   var mu sync.Mutex
    15   sem := make(chan struct{}, 4)
    16   for i, tile := range tiles {
    17     i, tile := i, tile // shadow
    18     go func() {
    19       sem <- struct{}{}
    20       defer func() { <-sem }()
    21       tmp := filepath.Join(os.TempDir(), fmt.Sprintf("preview_%d.gif", i))
    22       if err := generateGif(tile.Path, tmp); err != nil {
    23         log.Println(err)
    24         return
    25       }
    26       g, err := xwidget.NewAnimatedGif(storage.NewFileURI(tmp))
    27       if err != nil {
    28         log.Println(err)
    29         return
    30       }
    31       g.Start()
    32       mu.Lock()
    33       tile.Update(g)
    34       mu.Unlock()
    35     }()
    36   }
    37 }
    38 func generateGif(in, out string) error {
    39   cmd := exec.Command("ffmpeg",
    40     "-y",
    41     "-i", in,
    42     "-ss", "10",
    43     "-t", "3",
    44     "-vf", "fps=10,scale=200:-1:flags=lanczos",
    45     out,
    46   )
    47   var stderr bytes.Buffer
    48   cmd.Stderr = &stderr
    49   err := cmd.Run()
    50   if err != nil {
    51     return fmt.Errorf("%v: %s", err, stderr.String())
    52   }
    53   return nil
    54 }

Weil das alles Zeit braucht und den Rechner beansprucht, fährt updateGifs() ab Zeile 13 in Listing 2 die Produktion mit bis zu vier Goroutinen parallel. Der Channel sem mit einem vier Einträge großen Puffer synchronisiert den Ablauf, bei dem Zeile 19 eine leere Struktur in den Channel hineinschiebt, der blockt, falls bereits vier Einträge dort stehen. Die unter einem temporären Namen von ffmpeg 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 aufgerufene Funktion Update() auf die Tile-Struktur von Listing 1 bringt das Daumenkino auf den Schirm. Da Go von Haus aus bei parallel laufenden Goroutinen keine Schutzmechanismen gegen Datenkorruption implementiert, sorgen Lock() und Unlock() eines Mutex-Semaphors aus dem Paket sync dafür, dass sich parallel laufende Goroutinen in Zeile 33 nicht in die Quere kommen und Update() in aller Ruhe die mehrere Schritte lange Auffrischung vornehmen kann.

Schattenvariablen

Aufmerksame Leser werden sich vielleicht wundern, warum Zeile 17 die Schleifenvariablen i und tile dupliziert. Die Antwort ist verzwickt: Falls jede Schleifeniteration eine nebenläufige Goroutine abfeuert, weiß am Ende keiner mehr, welchen Werte die Schleifenvariable gerade hat. Falls die for-Schleife den Index i für den nächsten Durchang zum Beispiel gerade um Eins erhöht, ist die Goroutine zur Gif-Erzeugung vielleicht noch gar nicht am Ende angelangt, und auf einmal vergäbe sie i+1 als laufende Nummer der Gif-Datei statt i. Das Verfahren, Schleifenvariablen lokalen Variablen zuzuweisen, nennt man "Shadowing" und kommt in Go oft bei for-Schleifen mit Goroutinen zum Einsatz.

Nun noch zum Hauptprogramm in Listing 3, das die GUI auf den Schirm bringt, den Gif-Reigen startet und vom User ausgewählte Videos abschließend auf der Standardausgabe ausgibt, nachdem die GUI zusammengefaltet wurde.

Listing 3: vidgrep.go

    01 package main
    02 import (
    03   "bufio"
    04   "fmt"
    05   "os"
    06   "path/filepath"
    07   "strings"
    08   "fyne.io/fyne/v2"
    09   "fyne.io/fyne/v2/app"
    10   "fyne.io/fyne/v2/container"
    11   "fyne.io/fyne/v2/widget"
    12 )
    13 func readInput() []*Tile {
    14   var tiles []*Tile
    15   sc := bufio.NewScanner(os.Stdin)
    16   for sc.Scan() {
    17     p := sc.Text()
    18     if strings.ToLower(filepath.Ext(p)) == ".mov" {
    19       tiles = append(tiles, NewTile(p))
    20     }
    21   }
    22   return tiles
    23 }
    24 func main() {
    25   tiles := readInput()
    26   a := app.New()
    27   w := a.NewWindow("Select clips")
    28   cols := 4
    29   grid := container.NewGridWithColumns(cols)
    30   scroll := container.NewScroll(grid)
    31   for _, tile := range tiles {
    32     grid.Add(tile)
    33   }
    34   go updateGifs(tiles)
    35   done := widget.NewButton("Done", func() {
    36     w.Close()
    37   })
    38   w.SetContent(container.NewBorder(nil, done, nil, nil, scroll))
    39   w.Resize(fyne.NewSize(800, 600))
    40   w.Canvas().SetOnTypedKey(
    41     func(ev *fyne.KeyEvent) {
    42       key := string(ev.Name)
    43       switch key {
    44       case "Q":
    45         w.Close()
    46       }
    47     })
    48   w.ShowAndRun()
    49   for _, tile := range tiles {
    50     if tile.Selected {
    51       fmt.Println(tile.Path)
    52     }
    53   }
    54 }

Die guten ins Töpfchen

Listing 3 liest mit readInput() ab Zeile 13 die zeilenweise über die Standardeingabe eintrudelnden Videodateinamen ein. Zu jedem Video legt die Applikation mittels des Konstruktors NewTile() aus Listing 1 ein neues Daumenkinoguckloch an und schiebt dieses ans Ende des Arrays tiles, den die Funktion nach getaner Arbeit ans Hauptprogramm zurückreicht. Letzteres firmiert wie üblich als main ab Zeile 24 und erzeugt kurz nach Programmstart ein neues App-Fenster im Fyne-Framework.

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

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 auch beim Aufziehen des Fensters nicht in die Höhe. Das Grid-Widget hingegen sollte den gesamten restlichen Platz aufbrauchen, denn die Bildreihen können theoretisch ins Unermessliche wachsen.

Wachsen mit den Aufgaben

Damit der Video-Selektor (fast) beliebig viele Videos darstellen kann, auch falls der Platz im Applikations-Window dazu nicht ausreicht, liegt das Grid-Widget in einem Scroll-Widget (Zeile 30), das entsprechend der vom User eingestellten Applikations-Window-Größe einen Ausschnitt anzeigt, der sich mit der Maus verschieben lässt.

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 auch noch eine Kopfzeile stehen und links und rechts seitliche Randwidgets. Diese fehlen im vorliegenden Fall, und Zeile 38 setzt deshalb die nicht besetzten Parameter des Border-Layouts jeweils auf nil.

Fehlt nur noch, das Applikationsfenster mit Resize() aufzuziehen (Zeile 39), und 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 Haupteventschleife malt die GUI und reagiert auf User-Input, bis der User das Kommando zum Zusammenfalten gibt.

Abbildung 5: Das Border-Layout in Fyne

Neben Mausklicks auf den Button "Done" am unteren Ende des Fensters reagiert die Applikation auch noch auf Tastatureingaben. So führt ein Tastendruck auf "Q" genau wie die Button-Aktivierung zum GUI-Ende. Die Funktion SetOnTypedKey() des Canvas-Objektes in Zeile 40 definiert die dem Tastendruck zugeordnete Aktion.

Ist die Auswahl erfolgt, faltet w.Close() entweder in Zeile 36 (Button-Klick) oder in Zeile 45 (Tastendruck) die GUI zusammen und ShowAndRun() in Zeile 48 beendet sich. Was vor dem endgültigen Programmabbruch noch zu tun bleibt, ist durch alle definierten Tile-Strukturen zu iterieren und diejenigen, deren Selected-Flag gesetzt ist, auf der Standardausgabe auszugeben (Abbildung 7).

Abbildung 6: Mit der Maus selektiert der User Videos ...

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

Wie am Fließband

Der in Go übliche Dreisprung aus Listing 4 holt die Abhängigkeiten der Sourcen von Github ab und kompiliert das Ganze zu einem alles enthaltenden 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. Diese muss der Nutzer beispielsweise unter Ubuntu per sudo apt-get install nachinstallieren, damit ein anschließendes go build einer Fyne-Anwendung auf das erforderliche Fundament zurückgreifen kann.

Listing 4: build.sh

    1 $ go mod init vidgrep
    2 $ go mod tidy
    3 $ go build vidgrep.go gif.go tile.go
    4 $ 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. Alle angeklickten Videos löschen? Einfach eine Pipe mit | xargs rm dahinter stellen. Wer alle angeklickten Videodateien mit dem VLC-Player ansehen will, hängt | xargs -n 1 vlc --play-and-exit an. So ruft xargs den Player immer nur mit einer Datei aus der Pipe auf, spielt es ab und erst dann kommt der nächste Datei zum Zug. Die letzte Zeile in Listing 4 zeigt noch, wie xargs alle per GUI ausgewählten Videos auf den Desktop kopiert. Das mächtige Pipe-Konzept der Unix-Shell ist auch mehr als 50 Jahre nach seiner Entstehung ungeschlagen.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2026/07/snapshot/

[2]

Ab ins Kröpfchen" - Fotos aussortieren mit einer Desktop-GUI aus Go und Fyne, Linux-Magazin 11/2021, https://www.linux-magazin.de/ausgaben/2021/11/snapshot/

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.