Fotos sind dazu da, gezeigt und angesehen zu werden. Jedenfalls hat das Zeigen und Ansehen von Fotos in Teilen meiner Familie einen sehr hohen Stellenwert (einen noch höheren hat nur das Erstellen von Gruppenbildern). Bei größeren Familienfeiern kann man Leuten also eine Freude damit machen, Fotos irgendwie zu präsentieren. Die Kunst besteht dann darin, eine Form zu finden, die die Fotos zwar allen zugänglich macht und auch ein gemeinsames Ansehen ermöglicht, aber nicht alle ins kollektive Diashowschauen nötigt.
Zum 70. Geburtstag eines Elternteils habe ich mir also die Mühe gemacht, dafür eine Lösung zu finden: Ein Fernseher, ein Laptop, ein umgebautes Lenkrad-Pedal – fertig ist die interaktive Foto-Diashow:
- Auf einem Fernseher im Foyer läuft eine Art Diashow.
- Als Untertitel erscheint (soweit bekannt) Datum und Anlass des jeweiligen Fotos.
- Die Fotos werden zufällig aus einer manuell kuratierten Liste gewählt.
- Falls Fotos besonders interessant oder diskussionswürdig sind, gibt es eine Möglichkeit, die Diashow zu pausieren oder auch zurückzublättern.
Das Setup wurde bisher zweimal zum Einsatz gebracht und wurde gut angenommen. Es standen eigentlich immer Gäste in der Vergangenheit schwelgend vor dem Fernseher:





Überblick über das Setup
Das technische Setup sieht im Überblick folgendermaßen aus:
- Auf dem Laptop liegen die Fotos in einer bestimmten Ordnerstruktur
- Auf dem Laptop läuft ein Node-Webserver.
- Auf dem am Laptop z.B. per HDMI angeschlossenen Fernseher / Beamer wird ein Browser im Vollbildmodus gezeigt. Der Browser zeigt die Seite des lokalen Webservers und damit zufällige Bilder aus der Liste, die zusätzlich mit den Infos aus der Ordnerstruktur beschriftet sind.
- Es gibt ein “Gaspedal” (siehe unten), mit dem die Diashow angehalten bzw. vor- oder zurückgeblättert werden kann. In dem Gaspedal sitzt ein kleiner Arduino, der über USB am Laptop angeschlossen ist und über serielle Kommandos den Webserver mit steuern kann.
Zu 1) Die Fotos auf dem Laptop
Ich sortiere meine Fotos generell monatsweise in Ordnern. Innerhalb der Ordner gibt es dann entweder Einzelbilder oder Unterordner für größere Ereignisse oder Bildergruppen. Jeder Monatsorder hat einen Namen nach dieser Struktur:
1989-12 (Dezember)
Unterordner können nach diesem Muster benannt sein:
1989-12-08 Verlobung Bla und Blubb1989-12-08--1989-12-12 Ausflug nach ...
Aus dieser (Unter-) Ordnerstruktur kann man also Beschreibungen und Zeitpunkte extrahieren, die dann bei den Fotos mit angezeigt werden können. Das ist wichtig für die Zuschauer für den “Das ist schon 47 Jahre her, dass…”-Effekt.
Allerdings haben wir viele Fotos. Sehr viele. Da sind auch Tonnen an gescannten Dias dabei. Es hat sich gelohnt, die Bildersammlung vorher etwas zu “bearbeiten”. Hier eine lose Sammlung an Bash-Kommandos, mit denen ich meine Bilder verarbeitet habe. Das Wichtigste dabei war kleiner skalieren der Bilder, das hat alles doch deutlich flüssiger und übersichtlicher gemacht:
# Alle vorhandenen Dateitypen auflisten (case insensitive):
find . -type f | sed 's/.*\.\(.*\)/\L\1/' | sort -u
# Dateiendungen der JPGs lower case machen:
find . -type f -name "*.JPG" -exec sh -c 'mv "$1" "${1%.JPG}.jpgaux"' find-sh {} \;
find . -type f -name "*.jpgaux" -exec sh -c 'mv "$1" "${1%.jpgaux}.jpg"' find-sh {} \;
# webp nach jpg konvertieren:
find . -iname '*.webp' -exec mogrify -format jpg {} \; -delete
# jpgs auf 1920x1080 herunterskalieren:
find . -type f -iname '*.jpg' -exec sh -c 'echo $1 && convert "$1" -resize 1920x1080 "$1"' find-sh {} \;
# Dateitypen löschen, die wir nicht brauchen:
find . -type f -iname '*.ini' -exec rm -f {} \;
find . -type f -iname '*.pdf' -exec rm -f {} \;
find . -type f -iname '*.mpeg' -exec rm -f {} \;
find . -type f -iname '*.mp4' -exec rm -f {} \;
find . -type f -iname '*.mov' -exec rm -f {} \;
Damit ist die Datenbasis ready.
Zu 2) Der Node-Server
Als nächstes brauchen wir jetzt den Webserver, der sich die Fotos nimmt, zufällig welche auswählt, die durchwechselt, aus der Ordnerstruktur die Beschriftung bastelt und dann noch die Eingaben des “Gaspedals” verarbeitet.
Gebaut habe ich das in Node.js. Das hier ist die zentrale app.js:
const slideshowName = "Oma Nida";
const slideshowPort = 1953;
const slideShowTimeInterval = 10;
const slideshowHistoryLength = 20;
// Arduino:
const arduinoComPort = '/dev/ttyS10';
const arduinoBaudRate = 9600;
// enable authentication:
const basicAuth = false;
const authUser = "anita";
const authPass = "1953";
//include modules:
const fs = require('fs');
const path = require('path');
const fg = require('fast-glob');
const app = require('express')();
const http = require('http').Server(app);
const io = require('socket.io')(http);
// connect to Arduino and listen for commands:
const SerialPort = require('serialport');
const Readline = require('@serialport/parser-readline');
const port = new SerialPort(arduinoComPort, { baudRate: arduinoBaudRate }, function (err) {
if (err) {
return console.log("Serial port " + arduinoComPort + ": " + err.message + "\n");
}
else {
console.log("Serial port " + arduinoComPort + ": connected\n");
const parser = port.pipe(new Readline({ delimiter: '\n' }));
// read data from serial port:
port.on("open", () => {
console.log('serial port open');
});
var running = true;
parser.on('data', data =>{
console.log('< serial: ', data.trim());
if (running && data.trim() == "pause") {
console.log("> play: pause");
io.emit('play', 'pause');
running = false;
clearInterval(refreshIntervalId);
}
if (!running && data.trim() == "play") {
console.log("> play: play");
io.emit('play', 'play');
running = true;
sendDia();
refreshIntervalId = runDias();
}
if (data.trim() == "back") {
if (diaHistory.length >= 2) {
console.log("> play: back");
io.emit('play', 'back');
sendDia('back');
}
}
});
}
});
var mime = {
html: 'text/html',
txt: 'text/plain',
css: 'text/css',
gif: 'image/gif',
jpg: 'image/jpeg',
png: 'image/png',
svg: 'image/svg+xml',
js: 'application/javascript'
};
app.get('*', function (req, res) {
// authentication:
if (basicAuth) {
const reject = () => {
res.setHeader('www-authenticate', 'Basic');
res.sendStatus(401);
}
const authorization = req.headers.authorization
if(!authorization) {
return reject();
}
const [username, password] = Buffer.from(authorization.replace('Basic ', ''), 'base64').toString().split(':');
if(! (username === authUser && password === authPass)) {
return reject();
}
}
// serve static files from public/:
var dir = path.join(__dirname, 'public');
var file = path.join(dir, req.path.replace(/\/$/, '/index.html'));
if (file.indexOf(dir + path.sep) !== 0) {
return res.status(403).end('Forbidden');
}
var type = mime[path.extname(file).slice(1)] || 'text/plain';
var s = fs.createReadStream(decodeURIComponent(file));
s.on('open', function () {
res.set('Content-Type', type);
s.pipe(res);
});
s.on('error', function () {
res.set('Content-Type', 'text/plain');
res.status(404).end('Not found');
});
});
// on connection of a new browser:
io.on('connection', function(socket) {
console.log('Browser connected');
socket.on('disconnect', function () {
console.log('Browser disconnected');
});
});
// start webserver:
http.listen(slideshowPort, function () {
console.log('\n' + slideshowName + ' slideshow server running at http://localhost:' + slideshowPort + '/\n');
});
function runDias() {
var refreshIntervalId = setInterval(function(){
sendDia();
}, slideShowTimeInterval*1000);
return refreshIntervalId;
}
refreshIntervalId = runDias();
let diaHistory = [];
let diaFuture = [];
function sendDia(direction = null) {
if (direction == 'back') {
// stop timer:
clearInterval(refreshIntervalId);
// add tha actual dia to the future:
diaFuture.unshift(dia);
// choose last image:
dia = diaHistory[1];
// remove last image from history:
diaHistory.shift();
// start timer again:
refreshIntervalId = runDias();
}
else {
// check if there are dias in the future list:
if (diaFuture.length > 0) {
// choose the last image from the future list:
dia = diaFuture[0];
// remove last image from future list:
diaFuture.shift();
}
else {
// choose a random image:
dia = dias[Math.floor(Math.random()*dias.length)].replace("./public/", "");
}
// add this dia to the history:
diaHistory.unshift(dia);
// trim history to max length:
if (diaHistory.length > slideshowHistoryLength) {
diaHistory.pop();
}
}
console.log("> dia: ", dia);
io.emit('dia', dia);
// send length of future dias:
io.emit('futureLength', diaFuture.length);
}
// read images on startup:
console.log("Reading images...");
const dias = fg.sync(['./public/_fotos/**/*.jpg']);
console.log("Reading images... DONE! (" + dias.length + " images)");
Kleiner Hinweis dazu: Die erste Version habe ich bereits 2023 gebaut, und damals hat der Zugriff auf die seriellen Ports unter WSL2 nicht geklappt. Ich denke aber, dass das auch unter WSL2 lösbar sein sollte.
Der Webserver braucht noch eine index.html, die er an den Browser ausliefern kann. Das benötigte Javascript habe ich der Einfachheit halber direkt in die HTML-Datei gepackt:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body, html {
height: 100%;
margin: 0;
}
body {
background-color: black;
/* border: 3px green dashed; */
}
#dia {
height: 100%;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
/* border: 3px solid das; */
}
#subtitle {
position: absolute;
bottom: 0;
width: calc(100% - 40px);
padding: 20px;
text-align: center;
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-size: 50px;
font-family: sans-serif;
/* border: 3px solid red; */
}
#future {
position: absolute;
top: 20px;
width: 100%;
text-align: center;
color: #C00000;
font-size: 50px;
}
#playbutton > img {
width: 100px;
position: absolute;
top: 30px;
right: 30px;
}
</style>
<script src="/jquery.min.js"></script>
<script src = "/socket.io/socket.io.js"></script>
<script>
const socket = io();
socket.on('dia', function(dia){
// console.log("> SOCKET: ", dia);
// preloading images (see https://stackoverflow.com/questions/1977871/check-if-an-image-is-loaded-no-errors-with-jquery)
$('<img/>')
.on('load', function() {
// console.log("image loaded correctly");
$('#subtitle').animate({'opacity': 0}, 200);
$('#dia').animate({ opacity: 0 }, 200, function(){
$('#dia').css("background-image", "url('" + dia + "')");
$('#dia').animate({ opacity: 1 }, 500, function(){});
$('#subtitle').html(parseFilename(dia)).animate({'opacity': 1}, 600);
});
})
.on('error', function() { console.log("error loading image"); })
.attr("src", dia)
;
});
socket.on('play', function(playpause){
if (playpause == "play") {
$('#playbutton').fadeIn("fast", function() {
$('#playbutton').html("<img src='button_play.png'>");
});
buttontimer = setTimeout(
function() {
$('#playbutton').fadeOut("slow", function() {
$('#playbutton').html("");
});
}, 2500);
}
else if (playpause == "pause") {
if (typeof buttontimer !== 'undefined') {
clearInterval(buttontimer);
}
$('#playbutton').fadeIn("fast", function() {
$('#playbutton').html("<img src='button_pause.png'>");
});
}
else if (playpause == "back") {
$('#playbutton').fadeIn("fast", function() {
$('#playbutton').html("<img src='button_back.png'>");
});
buttontimer = setTimeout(
function() {
$('#playbutton').fadeOut("slow", function() {
$('#playbutton').html("");
});
}, 1000);
}
});
socket.on('futureLength', function(value){
console.log("futureLength: ", value);
$('#future').html("●".repeat(value));
});
function parseFilename(fileName) {
// Anzeigetext ordentlich parsen (WICHTIG: Es sind nicht immer gleich viele Pfad-Elemente!) IDEE: Immer OBERSTEN / LETZTEN Pfad für die Beschreibung nehmen?
fileParts = fileName.split('/');
subTitle = fileParts.at(-2);
subTitle = subTitle.replace(/\[.+?\]/g, "");
var datum = subTitle.split(/ (.+)/)[0];
var title = subTitle.split(/ (.+)/)[1];
if (!title) {
title = "";
}
// Datum ersetzen. Verschiedene Fälle:
// monate = ['', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
// monate = ['', 'Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sept.', 'Okt.', 'Nov.', 'Dez.'];
monate = ['', 'Jan', 'Feb', 'März', 'Apr', 'Mai', 'Juni', 'Juli', 'Aug', 'Sept', 'Okt', 'Nov', 'Dez'];
// 1985-__ > 1985
if (/^[0-9]{4}-__$/.test(datum)) {
jahr = datum.substring(0,4)
datum = jahr;
}
// 1985-__-__ > 1985
else if (/^[0-9]{4}-__-__$/.test(datum)) {
jahr = datum.substring(0,4)
datum = jahr;
}
// 1985-12-__ > Dez. 1985
else if (/^[0-9]{4}-[0-9]{2}-__$/.test(datum)) {
jahr = datum.substring(0,4);
monat = datum.substring(5,7);
datum = monate[parseInt(monat)] + " " + jahr;
}
// 1985-12-24 > 24. Dez. 1985
else if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(datum)) {
jahr = datum.substring(0,4);
monat = datum.substring(5,7);
tag = datum.substring(8,10)
datum = parseInt(tag) + ". " + monate[parseInt(monat)] + " " + jahr;
}
// 1985-__-__--1998-__-__ > 1985 - 1998
else if (/^[0-9]{4}-__-__--[0-9]{4}-__-__$/.test(datum)) {
jahr1 = datum.substring(0,4);
jahr2 = datum.substring(12,16);
datum = jahr1 + "–" + jahr2;
}
else if (/^[0-9]{4}-[0-9]{2}-__--[0-9]{4}-[0-9]{2}-__$/.test(datum) || /^[0-9]{4}-[0-9]{2}-__--[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(datum) || /^[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{4}-[0-9]{2}-__$/.test(datum)) {
// falls nur EIN Tag angegeben ist: ignorieren...
jahr1 = datum.substring(0,4);
monat1 = datum.substring(5,7);
jahr2 = datum.substring(12,16);
monat2 = datum.substring(17,19);
// 1985-10-__--1985-12-__ > Okt. - Dez. 1985
if (jahr1 == jahr2) {
datum = monate[parseInt(monat1)] + " – " + monate[parseInt(monat2)] + " " + jahr1;
}
// 1985-10-__--1986-02-__ > Okt. 1985 - Feb. 1986
else {
datum = monate[parseInt(monat1)] + " " + jahr1 + " – " + monate[parseInt(monat2)] + " " + jahr2;
}
}
else if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(datum)) {
jahr1 = datum.substring(0,4);
monat1 = datum.substring(5,7);
tag1 = datum.substring(8,10);
jahr2 = datum.substring(12,16);
monat2 = datum.substring(17,19);
tag2 = datum.substring(20,22);
// 1985-10-20--1985-10-25 > 20.-25. Okt. 1985
if (jahr1 == jahr2 && monat1 == monat2) {
datum = tag1 + ".–" + tag2 + ". " + monate[parseInt(monat1)] + " " + jahr1;
}
// 1985-10-20--1985-11-05 > 20. Okt. - 5. Nov. 1985
else if (jahr1 == jahr2) {
datum = tag1 + ". " + monate[parseInt(monat1)] + " " + tag2 + ". " + monate[parseInt(monat2)] + " " + jahr1;
}
// 1985-10-20--1986-01-05 > 20. Okt. 1985 - 5. Jan. 1986
else {
datum = tag1 + ". " + monate[parseInt(monat1)] + jahr1 + " – " + tag2 + ". " + monate[parseInt(monat2)] + " " + jahr2;
}
}
return "<b>" + datum + "</b> <i>" + title + "</i>";
}
</script>
</head>
<body>
<div id="dia"></div>
<div id="subtitle"></div>
<div id="future"></div>
<div id="playbutton"></div>
</body>
</html>
Dann werden noch eine jquery.min.js und diese Buttons gebraucht:



Alle weiteren benötigten Pakete werden in einer package.json definiert:
{
"name": "omanidaslideshow",
"version": "1.2.0",
"description": "A browser-based slideshow, controllable with a foot pedal.",
"main": "app.js",
"dependencies": {
"express": "^4.17.1",
"fast-glob": "^3.2.7",
"glob": "^7.2.0",
"globby": "^12.0.2",
"nodemon": "^3.0.1",
"serialport": "^9.2.4",
"socket.io": "^4.3.1"
},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Daniel Weber"
}
Die Pakete werden dann wie üblich installiert:
npm install
Dann sollte der Server gestartet werden können:
nodemon app.js -L
Zu 3) Die Fotos im Browser
Im Browser erscheinen jetzt unter dieser Adresse die Bilder:
http://localhost:1953/
Den Browser auf den Fernseher oder Beamer schieben, Vollbild aktivieren (F11) und: Voilà!
Zu 4) Das “Gaspedal”
Damit die Zuschauenden Einfluss auf die Diashow nehmen können gab es auf dem Boden vor dem Fernseher (mit langem Kabel) ein umgebautes Gaspedal eines alten PC-Lenkrads mit 2 Pedalen. Hinter jeden Pedal steckt ein einfaches Poti, welche mit einem kleinen Arduino Pro Micro ausgelesen wurden. Bei einem gewissen Schwellwert (“Pedal ist gedrückt”) gibt der Arduino dann simple serielle Kommandos aus, die vom Webserver verarbeitet werden (s.o.). Hier ein paar Bilder des (etwas lieblos beklebten…) Pedals:





Das ist der Code, der auf dem Arduino läuft:
#define PinLinks A2
#define PinRechts A3
int posRechts = 0;
int posLinks = 0;
int posLastRechts = 0;
int posLastLinks = 0;
void setup() {
Serial.begin(9600);
}
void loop() {
// // Ausgabe für den Seriellen Plotter:
// Serial.print(analogRead(PinLinks));
// Serial.print(",");
// Serial.println(analogRead(PinRechts));
posRechts = analogRead(PinRechts);
posLinks = analogRead(PinLinks);
if (posRechts < 400 && posLastRechts > 400) {
Serial.println("pause");
}
if (posRechts > 700 && posLastRechts < 700) {
Serial.println("play");
}
if (posLinks < 400 && posLastLinks > 400) {
Serial.println("back");
}
posLastRechts = posRechts;
posLastLinks = posLinks;
delay(50);
}
Fazit
Ein Bastelprojekt, dessen Entwicklung und Bau sehr viel Spaß gemacht hat! Vor allem das Zusammenspiel der verschiedenen Komponenten, ich mag einfach die Kombination aus Software und physischer Hardware. Das ganze Projekt wurde auch noch zu Zeiten gebaut, als Code noch manuell geschrieben werden musste. Möglicherweise könnte man (also ich) das heute etwas eleganter und vor allem schneller lösen (lassen). Aber im Rückblick muss ich sagen, dass die Befriedigung, wenn man ein solches Projekt wirklich zu Fuß realisiert hat, doch etwas größer war als es heute unter Zuhilfenahme von Claude Code und Konsorten ist. Wobei auch das seine Faszination hat…
Das Wichtigste: Am Ende hat es auch den Gästen auf den Festen Freude gemacht, das zufällig in Erinnerungen geworfen werden, das Gespräch darüber, auch ein bisschen die Spielerei. So muss das sein.