Aller au contenu principal
GiwiSoft
Splash

Photobooth Desktop : une cabine photo de la webcam à l'impression

Giwi 11 min de lecture JavaScript, Réalisations

Après la version web de ma cabine photo, j’ai voulu une déclinaison desktop : une app Electron installable sur un poste d’événement, pilotable à la souris mais aussi au clavier et à la manette, avec impression directe sur une photo 4×6. Voici comment est né Photobooth Desktop.

Le principe reste le même : capture webcam, superposition de fonds PNG, enregistrement ou impression du résultat. La version desktop ajoute le plein écran kiosque, les raccourcis clavier, le support manette et l’impression.

Pourquoi ne pas se contenter de la version web ? Parce qu’un événement réel (mariage, anniversaire, séminaire) impose des contraintes que le navigateur gère mal : il faut une interface plein écran sans la moindre barre d’outils, la possibilité d’autoriser la webcam une seule fois et pour de bon, des raccourcis physiques (un bouton qui déclenche le compte à rebours), une manette pour ceux qui ne touchent pas à la souris, et l’envoi direct vers une imprimante photo. Toutes ces briques se branchent naturellement dans une app Electron, là où elles seraient laborieuses (voire impossibles) dans une page web classique.

Stack technique

Couche Technologie
Runtime Electron 43
UI Preact 10 (+ TypeScript)
Bundle esbuild
Packaging electron-builder (deb, rpm, AppImage, NSIS)
Capture getUserMedia + Canvas 2D
i18n JSON (en, fr, de, es)

Une stack volontairement minimale : pas de framework Electron lourd, pas de bundler configurable à outrance. Preact + esbuild tiennent le rendu, le reste est du Node natif.

J’ai fait le choix de Preact plutôt que React parce que pour un renderer aussi simple (une vue plein écran, un overlay de réglages, quelques composants), les ~3 Ko de Preact suffisent largement et gardent le démarrage instantané. quant à esbuild, il est incroyablement rapide : le hot-reload est quasi instantané, ce qui accélère énormément l’itération sur le rendu canvas. Et electron-builder produit en une seule commande les paquets pour chacune des plateformes cibles, y compris les AppImage et les deb pour Linux.

Architecture

L’application suit l’architecture Electron classique à trois processus :

src/main/index.ts        → processus principal (IPC, fs, impression)
src/main/preload.ts → pont contextIsolation
src/mainview/ → renderer Preact (App, Viewport, SettingsOverlay…)
src/mainview/lib/ → canvas.ts, rpc.ts, constants.ts, utils.ts

Cette séparation n’est pas un luxe : elle est imposée par le modèle de sécurité d’Electron, et elle structure naturellement le code. Le processus principal vit dans Node et touche au système de fichiers, à l’impression et à la gestion des fenêtres. Le renderer (un Preact dans une page web) fait tout le rendu visuel. Entre les deux, un preload expose un pont minimal et sécurisé.

Tout passe par un pont IPC 1:1 avec le renderer : chaque ipcMain.handle correspond à une fonction de lib/rpc.ts. La sécurité est standard (contextIsolation: true, nodeIntegration: false, navigation externe redirigée vers le navigateur système) :

function register<Params, Response>(channel: string, handler: (params: Params) => Response | Promise<Response>) {
ipcMain.handle(channel, (_event, params: Params) => handler(params));
}

L’avantage du pont 1:1 est la lisibilité : on sait d’un coup d’œil, en lisant rpc.ts, l’ensemble des capacités offertes au renderer. Il n’y a pas de wifi magique ; chaque canal est déclaré et typé. Le renderer n’a par ailleurs aucun accès direct à Node : tout ce qu’il fait passe par ce pont, ce qui limite la surface d’attaque en cas d’XSS.

Gestion des données : le bundle n’est pas le data store

Point important pour une app distribuée en AppImage/deb : le bundle est en lecture seule (ou écrasé à chaque rebuild). Config et fonds vivent donc dans le cache dir OS, photos dans le dossier Images :

function dataDir(): string {
if (process.platform === "darwin") return join(home, "Library", "Caches", "photobooth");
if (process.platform === "win32") return join(process.env.LOCALAPPDATA || ..., "photobooth");
return join(process.env.XDG_CACHE_HOME || join(home, ".cache"), "photobooth");
}
// Photos → ~/Pictures/photobooth (Linux/macOS), %USERPROFILE%\Pictures\photobooth (Windows)

C’est une erreur classique quand on déploie une app desktop : on croit pouvoir écrire dans le dossier du binaire, mais sur un AppImage monté en lecture seule, ou un deb réinstallé au prochain update, tout ce qui est écrit là est perdu. En séparant clairement le code (bundle, en lecture seule) des données (cache + Images, en lecture/écriture), on garantit que les fonds importés par l’utilisateur survivent aux mises à jour.

Au premier lancement, les défauts sont seedés depuis le bundle : les PNG de backgrounds/ sont copiés vers le cache, le config.json par défaut également. L’utilisateur peut ensuite importer ses propres fonds depuis les réglages (chaque import est sanitisé, nom de fichier assaini, et dédupliqué).

La sanitisation des imports est un détail qui vaut de l’or : un nom de fichier peut contenir des / ou des .. qui, naïvement concaténés, pourraient écrire hors du dossier prévu. En assainissant (en remplaçant les caractères dangereux) et en dédupliquant (hachage du contenu), on élimine à la fois un bug potentiel et un risque de sécurité.

Capture et composition sur Canvas

Le cœur du rendu, c’est du Canvas 2D pur. Trois fonctions font tout.

Je tiens à rester sur du Canvas 2D natif plutôt que sur WebGL ou une lib comme PixiJS : pour superposer une vidéo et quelques PNG, le Canvas 2D est parfaitement suffisant, réduit la complexité, et n’introduit aucune dépendance. Les trois fonctions ci-dessous couvrent la quasi-totalité des cas de composition.

Le recadrage cover-fit de la vidéo

La webcam a un ratio quelconque ; la photo finale est fixe (1800×1200). On centre-crop la vidéo pour couvrir le cadre :

export function drawVideoCrop(c: CanvasRenderingContext2D, video: HTMLVideoElement) {
const vw = video.videoWidth, vh = video.videoHeight;
const scale = Math.max(W / vw, H / vh);
const sw = vw * scale, sh = vh * scale;
c.drawImage(video, (W - sw) / 2, (H - sh) / 2, sw, sh);
}

Le principe du cover-fit est le même que object-fit: cover en CSS : on agrandit la vidéo jusqu’à ce qu’elle couvre la totalité du cadre, puis on coupe les débords. Math.max(W/vw, H/vh) garantit que la dimension la plus contraignante est remplie. Résultat : jamais de bandes noires, quel que soit l’angle de la webcam, et un cadrage centré qui montre le sujet là où il est le plus naturel.

Le fond PNG positionnable

Un fond est rarement exactement au ratio de la photo. Plutôt que de l’étirer, on le cover-fit puis on l’ancre selon une position configurable (top, left bottom, 40%…) :

export function drawBgTo(c, img, cw, ch, position) {
const scale = Math.min(cw / img.naturalWidth, ch / img.naturalHeight);
const sw = img.naturalWidth * scale, sh = img.naturalHeight * scale;
const { x, y } = parsePosition(position);
c.drawImage(img, (cw - sw) * x, (ch - sh) * y, sw, sh);
}

parsePosition traduit la chaîne (top, left bottom, 40%…) en coordonnées d’ancrage normalisées 0..1. Utile pour un décor haut dont on veut montrer le sommet.

Prenons un exemple concret : un fond en hauteur avec un texte en bas. Si on le couvre de façon centrée, le bas du texte sera coupé. En positionnant à bottom (donc y proche de 1), on décale le fond vers le haut et on garde visible la partie la plus importante. Ce petit système d’ancrage, exprimé en pourcentages ou en mots-clés, couvre tous les cas de mise en page d’un décor.

Le mode photo strip

Quatre captures sont assemblées en grille 2×2, chaque shot réduit de moitié avec un gap de 2 px :

export function createStrip(frames: ImageData[]): string {
const gap = 2, cw = W / 2, ch = H / 2;
const c = document.createElement("canvas");
c.width = W; c.height = H;
const positions = [[0, 0], [cw + gap, 0], [0, ch + gap], [cw + gap, ch + gap]];
frames.forEach((imageData, i) => {
const pos = positions[i];
if (!pos) return;
// each frame drawn halved into its quadrant
});
return c.toDataURL("image/png");
}

Le photo strip est une option appréciée des invités : quatre poses dans une seule photo à découper. Chaque capture est réduite de moitié et placée dans son quadrant, avec un petit espace de 2 px pour éviter que les images ne se touchent. Le canvas final fait toujours 1800×1200, donc une seule taille d’impression à gérer.

Enregistrement et impression

La photo est encodée en data URL PNG côté renderer puis envoyée au processus principal. Celui-ci l’écrit dans ~/Pictures/photobooth et, si demandé, la confie à lp (l’imprimante CUPS) avec les options 4×6 glossy :

register("savePhoto", async ({ image, print }: { image: string; print: boolean }) => {
const base64 = image.split(",")[1];
const buffer = Buffer.from(base64, "base64");
const filename = `photo-${Date.now()}.png`;
writeFileSync(join(PHOTOS_DIR, filename), buffer);
if (print) {
spawn("lp", ["-o", "media=4x6in", "-o", "MediaType=Glossy", filepath]);
}
return { filename };
});

Simple et efficace : pas de lib d’impression, lp fait le travail sur Linux. Sur Windows, le même chemin d’enregistrement fonctionne, l’impression passe par le dialogue système.

Pourquoi cette approche “jeter le binaire dans CUPS” plutôt qu’une bibliothèque d’impression ? Parce que lp est omniprésent sur les systèmes Linux déjà configurés pour une imprimante photo, et qu’il gère nativement le profil papier et le type de support. Adapter une vraie bibliothèque d’impression à toutes les plateformes aurait été des centaines de lignes pour un résultat identique. La photo est sauvée avant l’impression ; ainsi, même si l’imprimante tombe en panne, le cliché n’est jamais perdu.

Mode kiosque

Pour un usage événementiel, l’app gère ses permissions webcam sans prompt (impossible dans une fenêtre kiosque) :

session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
callback(permission === "media" || permission === "mediaKeySystem");
});

Sans ce handler, Electron afficherait une popup de permission à chaque lancement, ce qui est rédhibitoire sur un poste laissé sans surveillance pendant un mariage. On autorise une seule fois, et uniquement les permissions relatives au média : rien d’autre n’est jamais accordé.

Et un splash screen (400×280, sans cadre, toujours au premier plan) affiche la progression de chargement pendant que le renderer précharge les fonds, avant de laisser place à la fenêtre principale 1400×900.

Le splash a une double fonction : masquer le temps de préchargement des fonds (qui peut prendre une seconde ou deux quand il y a beaucoup de PNG) et rassurer l’opérateur que l’app démarre bien. Comme il est sans cadre et toujours au premier plan, aucune autre fenêtre ne vient s’interposer.

Fonctionnalités

  • Flux webcam en direct avec superpositions PNG en temps réel
  • Positionnement des fonds : top, bottom, left, right, center, pourcentages
  • Capture 1800×1200 paysage (10×15 cm à 300 DPI)
  • Photo strip : 4 shots en grille 2×2
  • Compte à rebours personnalisable (3s, 5s, 10s)
  • Mode miroir, sélecteur de caméra
  • Raccourcis clavier configurables et support manette (boutons + axes)
  • i18n : anglais, français, allemand, espagnol
  • Watermark optionnel (barre semi-transparente en bas de photo)
  • Réglages dans l’app (plus d’édition manuelle de config.json), export/import des paramètres et fonds en un seul JSON

Parmi ces fonctionnalités, deux méritent une mention particulière pour un usage événementiel. Le support manette est la fonctionnalité que les invités utilisent le plus volontiers : un simple bouton de manette déclenche le compte à rebours, ce qui est beaucoup plus naturel qu’une souris pour quelqu’un qui n’est pas derrière le poste. Et l’export/import en un seul JSON est le couteau suisse de l’organisateur : on configure une fois l’appli (fonds, réglages, wording) et on la déploie telle quelle sur un autre poste en restaurant le fichier.

Packaging

Le build produit des paquets natifs pour chaque plateforme :

yarn electron:dev    # Build bundles + lance Electron (DevTools auto-open)
yarn electron:dist # Build deb, rpm, AppImage dans dist/

L’installation se fait ensuite comme n’importe quelle app : dpkg -i, rpm -i ou AppImage. Product page : giwi.github.io/photobooth-desktop.

Le fait de viser le format natif (deb, rpm, AppImage) plutôt qu’un paquet universel a deux avantages concrets sur un poste d’événement : l’installation se fait sans outil tiers particulier, et la mise à jour est un simple remplacement de paquet qui préserve les données de l’utilisateur (puisque celles-ci vivent dans le cache, pas dans le bundle).

Le projet est open source (Apache-2.0) : github.com/Giwi/photobooth-desktop. Si vous montez un photobooth pour un mariage ou un anniversaire, cette version desktop est celle qui tourne le mieux sur un PC dédié.

J’espère que ce retour d’expérience vous sera utile. Les choix faits ici (bundle en lecture seule + données en cache, IPC 1:1 typé, Canvas 2D minimal, impression via CUPS) sont, à mon sens, la combinaison la plus simple qui tienne la route pour une app Electron distribuée sur plusieurs plateformes. Si vous avez des questions ou des idées, les issues du dépôt sont ouvertes.