#!/usr/bin/env python3 """CRT photobooth. States: IDLE (background + text) -> PREVIEW (live feed, "get ready") -> CAPTURING (countdowns, flashes, 3 photos per shoot). Every 4th shoot the 12 photos are composed onto a contact sheet and printed. Five minutes of inactivity brings up the fish tank screensaver. While running, the booth re-execs itself under systemd-inhibit so the machine will not sleep or suspend (and, when the lock is available, ignores the laptop lid) - it can run headless with the lid closed. Run with --windowed for testing without taking over the display. Esc or Q quits. """ import os import shutil import subprocess import sys import time import tomllib from pathlib import Path # On a console-only machine (no X or Wayland session), tell SDL to draw # straight to the screen via KMS/DRM, so no desktop environment is needed. if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): os.environ.setdefault("SDL_VIDEODRIVER", "kmsdrm") import pygame import camera as cam import contact_sheet import falling_photos import screensaver ROOT = Path(__file__).resolve().parent IDLE, PREVIEW, COUNTDOWN, FLASH, SCREENSAVER = range(5) KEEP_AWAKE_ENV = "PHOTOBOOTH_INHIBITED" KEEP_AWAKE_WHY = "Photobooth is running (keep awake, ignore lid)" def _probe_inhibit_locks(): """Largest lock set systemd-inhibit can hold on this machine. Returns a --what value like "sleep:idle:handle-lid-switch", or None when systemd-inhibit is missing or no lock can be taken. handle-lid-switch allows only one block holder, so it is skipped when a desktop environment already holds it. Each probe runs `true` under the lock, so locks are taken and released instantly. """ inhibit = shutil.which("systemd-inhibit") if not inhibit: return None for what in ("sleep:idle:handle-lid-switch", "sleep:idle", "sleep"): probe = subprocess.run([inhibit, "--mode=block", f"--what={what}", "--", "true"], capture_output=True) if probe.returncode == 0: return what return None def reexec_keep_awake(): """Re-exec this process under systemd-inhibit (once) so the machine stays awake - and ignores the lid when that lock is free - while the booth runs. Locks are released when the program exits. Best-effort: hosts without systemd-inhibit simply run without locks.""" if os.environ.get(KEEP_AWAKE_ENV): return what = _probe_inhibit_locks() if not what: return os.environ[KEEP_AWAKE_ENV] = "1" argv = [shutil.which("systemd-inhibit"), "--mode=block", f"--what={what}", f"--why={KEEP_AWAKE_WHY}", "--", sys.executable, str(ROOT / "photobooth.py"), *sys.argv[1:]] # flush: execvp replaces the process without draining stdout's buffer. print(f"Keep-awake: re-running under systemd-inhibit ({what})", flush=True) try: os.execvp(argv[0], argv) except OSError as e: print(f"Keep-awake: re-exec failed ({e}); running without locks") def load_config(): with open(ROOT / "config.toml", "rb") as f: return tomllib.load(f) def load_fish_images(fish_dir): images = [] for path in sorted(fish_dir.iterdir()): if path.suffix.lower() in (".png", ".jpg", ".jpeg", ".gif", ".bmp"): images.append(pygame.image.load(path).convert_alpha()) return images class Booth: def __init__(self, windowed=False): self.cfg = load_config() disp = self.cfg["display"] self.size = (disp["width"], disp["height"]) pygame.init() flags = 0 if windowed else pygame.FULLSCREEN self.screen = pygame.display.set_mode(self.size, flags) pygame.display.set_caption("Photobooth") pygame.mouse.set_visible(False) self.clock = pygame.time.Clock() h = self.size[1] self.font_big = pygame.font.Font(None, h // 3) # countdown digits self.font_title = pygame.font.Font(None, h // 6) self.font_med = pygame.font.Font(None, h // 12) self.font_small = pygame.font.Font(None, h // 18) bg_path = ROOT / "assets" / "background.png" self.background = pygame.transform.smoothscale( pygame.image.load(bg_path).convert(), self.size) self.photos_dir = ROOT / "photos" self.sheets_dir = ROOT / "contact_sheets" self.photos_dir.mkdir(exist_ok=True) self.sheets_dir.mkdir(exist_ok=True) c = self.cfg["camera"] self.camera = cam.Camera(c["device"], c["capture_width"], c["capture_height"]) self.mirror = c.get("mirror_preview", True) self.fish_images = load_fish_images(ROOT / "fish") self.falling = falling_photos.FallingPhotos(self.size, self.photos_dir) booth = self.cfg["booth"] self.photos_per_shoot = booth["photos_per_shoot"] self.shoots_per_print = booth["shoots_per_print"] self.state = IDLE self.last_activity = time.monotonic() self.tank = None self.notice = None # (message, expiry_time) self.photos_in_shoot = 0 self.countdown_end = 0.0 self.flash_end = 0.0 # Resume mid-cycle after a crash: existing photos count toward the # current print cycle. self.shoots_done = len(self.saved_photos()) // self.photos_per_shoot # ---------- helpers ---------- def saved_photos(self): return sorted(self.photos_dir.glob("*.jpg")) def set_notice(self, message, seconds=6): self.notice = (message, time.monotonic() + seconds) def draw_text(self, text, font, center, color=(255, 255, 255)): shadow = font.render(text, True, (0, 0, 0)) label = font.render(text, True, color) rect = label.get_rect(center=center) offset = max(2, font.get_height() // 20) self.screen.blit(shadow, rect.move(offset, offset)) self.screen.blit(label, rect) def draw_camera_feed(self): frame = self.camera.get_frame() if frame is None: self.screen.fill((20, 20, 20)) self.draw_text("Warming up camera...", self.font_med, (self.size[0] // 2, self.size[1] // 2)) else: self.screen.blit(cam.frame_to_surface(frame, self.size, self.mirror), (0, 0)) if self.camera.looks_black(): self.draw_text("Camera problem: unplug it and plug it back in!", self.font_small, (self.size[0] // 2, self.size[1] // 8), (255, 120, 120)) # ---------- states ---------- def press(self): """Handle a button (mouse) press in the current state.""" self.last_activity = time.monotonic() if self.state == SCREENSAVER: self.state = IDLE self.tank = None elif self.state == IDLE: self.state = PREVIEW elif self.state == PREVIEW: self.photos_in_shoot = 0 self.start_countdown(self.cfg["booth"]["first_countdown_seconds"]) # COUNTDOWN / FLASH ignore presses def start_countdown(self, seconds): self.state = COUNTDOWN self.countdown_end = time.monotonic() + seconds def take_photo(self): stamp = time.strftime("%Y%m%d_%H%M%S") path = self.photos_dir / ( f"shoot{self.shoots_done + 1}_photo{self.photos_in_shoot + 1}_{stamp}.jpg") if self.camera.save_photo(str(path)): self.photos_in_shoot += 1 else: self.set_notice("Camera error - photo not saved!") self.state = FLASH self.flash_end = time.monotonic() + self.cfg["booth"]["flash_duration_ms"] / 1000 def finish_shoot(self): self.shoots_done += 1 if self.shoots_done >= self.shoots_per_print: self.print_cycle() self.state = IDLE self.last_activity = time.monotonic() def print_cycle(self): # Blocking is fine here: composing + queueing takes a second or two, # and the "Printing..." screen explains the pause. self.screen.fill((0, 0, 40)) self.draw_text("Printing...", self.font_title, (self.size[0] // 2, self.size[1] // 2)) pygame.display.flip() photos = self.saved_photos() stamp = time.strftime("%Y%m%d_%H%M%S") sheet_path = self.sheets_dir / f"sheet_{stamp}.jpg" cs = self.cfg.get("contact_sheet", {}) contact_sheet.build_sheet( photos, sheet_path, footer_cfg=self.cfg.get("footer"), photos_per_shoot=self.photos_per_shoot, shoots_per_print=self.shoots_per_print, margin=cs.get("margin_px", 120), photo_gutter=cs.get("photo_gutter_px", 30), strip_gutter=cs.get("strip_gutter_px", 30), auto_orient=cs.get("auto_orient", True), ) printing = self.cfg["printing"] if printing["enabled"]: ok, message = contact_sheet.print_sheet(sheet_path, printing["printer"]) else: ok, message = True, "printing disabled in config" if ok: for photo in photos: photo.unlink() self.falling.reload() if printing["delete_contact_sheets"]: sheet_path.unlink() sheet_path.with_suffix(".pdf").unlink(missing_ok=True) self.shoots_done = 0 self.set_notice("Sent to printer!") else: # Keep everything so nothing is lost; the sheet can be printed # by hand and the counter stays full until it succeeds. self.shoots_done = 0 self.set_notice(f"Print failed: {message}", seconds=15) print(f"PRINT FAILED ({message}). Sheet saved at {sheet_path}", file=sys.stderr) # ---------- drawing ---------- def draw_idle(self): self.screen.blit(self.background, (0, 0)) self.falling.draw(self.screen) text = self.cfg["text"] w, h = self.size self.draw_text(text["idle_title"], self.font_title, (w // 2, h // 4)) self.draw_text(text["idle_subtitle"], self.font_med, (w // 2, h // 2)) left = self.shoots_per_print - self.shoots_done self.draw_text(text["shoots_left_format"].format(n=left), self.font_med, (w // 2, h * 3 // 4), (255, 230, 120)) if self.notice: message, expiry = self.notice if time.monotonic() > expiry: self.notice = None else: self.draw_text(message, self.font_small, (w // 2, h * 7 // 8), (255, 150, 150)) def draw_preview(self): self.draw_camera_feed() self.draw_text(self.cfg["text"]["get_ready"], self.font_med, (self.size[0] // 2, self.size[1] * 7 // 8)) def draw_countdown(self): self.draw_camera_feed() remaining = self.countdown_end - time.monotonic() if remaining <= 0: self.take_photo() return self.draw_text(str(int(remaining) + 1), self.font_big, (self.size[0] // 2, self.size[1] // 2)) def draw_flash(self): self.screen.fill((255, 255, 255)) if time.monotonic() >= self.flash_end: if self.photos_in_shoot >= self.photos_per_shoot: self.finish_shoot() else: self.start_countdown(self.cfg["booth"]["next_countdown_seconds"]) # ---------- main loop ---------- def run(self): timeout = self.cfg["screensaver"]["timeout_seconds"] while True: dt = self.clock.tick(30) / 1000 for event in pygame.event.get(): if event.type == pygame.QUIT: return if event.type == pygame.KEYDOWN: if event.key in (pygame.K_ESCAPE, pygame.K_q): return self.last_activity = time.monotonic() if event.type == pygame.MOUSEBUTTONDOWN: self.press() if (self.state in (IDLE, PREVIEW) and time.monotonic() - self.last_activity > timeout): self.state = SCREENSAVER self.tank = screensaver.FishTank( self.size, self.fish_images, self.cfg["screensaver"]["num_fish"]) if self.state == IDLE: self.falling.update(dt) self.draw_idle() elif self.state == PREVIEW: self.draw_preview() elif self.state == COUNTDOWN: self.draw_countdown() elif self.state == FLASH: self.draw_flash() elif self.state == SCREENSAVER: self.tank.update(dt) self.tank.draw(self.screen) pygame.display.flip() def close(self): self.camera.close() pygame.quit() def main(): reexec_keep_awake() windowed = "--windowed" in sys.argv booth = Booth(windowed=windowed) try: booth.run() finally: booth.close() if __name__ == "__main__": main()