From 755fa0632c88a7df76e399432a834e1f6e372244 Mon Sep 17 00:00:00 2001 From: trav Date: Sat, 18 Jul 2026 18:36:51 -0400 Subject: [PATCH] aspect ratio and keep awake --- README.md | 22 +++++++++++++++--- contact_sheet.py | 21 +++++++++++++---- photobooth.py | 53 +++++++++++++++++++++++++++++++++++++++++++ test_contact_sheet.py | 17 +++++++++----- 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ec5e655..27765d8 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,21 @@ When idle for 5 minutes, a fish-tank screensaver protects the CRT from burn-in. Add `--windowed` to test in a window instead of fullscreen. **Press Esc or Q to quit.** +## Running headless (lid closed) + +While it is running, the booth holds systemd keep-awake locks +(`systemd-inhibit`, block mode: `sleep`, `idle`, and `handle-lid-switch` +when no desktop environment already holds it). The laptop will not suspend +or idle-sleep, and — on machines where logind manages the lid — closing +the lid does nothing, so the laptop can run tucked away behind the CRT. +The locks are released as soon as the program exits. + +If the machine still suspends when the lid closes (some desktop +environments keep the lid lock for themselves), disable lid suspend on +that machine: put `HandleLidSwitch=ignore` in +`/etc/systemd/logind.conf.d/photobooth.conf` (needs root, applies even +when the booth is not running) or use the desktop's own power settings. + ## Customizing - **All settings** live in `config.toml` — camera choice, screen text, @@ -84,9 +99,10 @@ To use a different camera: automatically after a successful print. (If the program is restarted mid-cycle, photos here are counted so no progress is lost.) - `contact_sheets/` — every printed sheet, kept as a backup in case of - printer trouble. Delete them manually now and then, or set - `delete_contact_sheets = true` in the config to remove each one right - after it prints. + printer trouble. Sheets are full US-letter pages at 300 DPI, so they + also print at the right size from any other app. Delete them manually + now and then, or set `delete_contact_sheets = true` in the config to + remove each one right after it prints. ## If printing fails diff --git a/contact_sheet.py b/contact_sheet.py index a646546..0189493 100644 --- a/contact_sheet.py +++ b/contact_sheet.py @@ -17,7 +17,9 @@ that maximizes each printed photo's area on a fixed 8.5x11 letter sheet a strip has few photos and a wide page fills better (e.g. 1 photo per shoot, or one big photo). -The date is printed only inside the footer. +The date is printed only inside the footer. Sheets are saved with 300-DPI +metadata so their physical print size is well-defined (a full letter page) +for CUPS or any other program they are printed from. """ import subprocess @@ -229,13 +231,24 @@ def build_sheet(photo_paths, out_path, footer_cfg=None, for (x, y, w, h) in layout["footer_rects"]: sheet.paste(footer_img, (x, y)) - sheet.save(out_path, quality=95) + # Stamp 300 DPI into the file: without it the sheet has no defined + # physical size and the print pipeline has to guess (and may stretch). + sheet.save(out_path, quality=95, dpi=(300, 300)) return out_path def print_sheet(path, printer=""): - """Send the sheet to the printer via lp. Returns (ok, message).""" - cmd = ["lp", "-o", "media=letter", "-o", "fit-to-page"] + """Send the sheet to the printer via lp. Returns (ok, message). + + fit-to-page scales uniformly, so the sheet's aspect ratio is preserved. + Passing the sheet's orientation explicitly keeps a landscape sheet from + being rotated or squeezed by driver defaults. + """ + with Image.open(path) as im: + sheet_w, sheet_h = im.size + orientation = "landscape" if sheet_w > sheet_h else "portrait" + cmd = ["lp", "-o", "media=letter", "-o", "fit-to-page", + "-o", orientation] if printer: cmd += ["-d", printer] cmd.append(str(path)) diff --git a/photobooth.py b/photobooth.py index 86c5d68..f2ca28b 100644 --- a/photobooth.py +++ b/photobooth.py @@ -6,10 +6,17 @@ 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 @@ -26,6 +33,51 @@ 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: @@ -290,6 +342,7 @@ class Booth: def main(): + reexec_keep_awake() windowed = "--windowed" in sys.argv booth = Booth(windowed=windowed) try: diff --git a/test_contact_sheet.py b/test_contact_sheet.py index ce6c52d..23899e0 100644 --- a/test_contact_sheet.py +++ b/test_contact_sheet.py @@ -92,12 +92,13 @@ def check_layout_combos(): assert ys == sorted(ys), f"pps={pps} spp={spp} strip {strip_i}: not top-to-bottom" for a, b in zip(rects, rects[1:]): assert b[1] >= a[1] + a[3], "photos overlap vertically" - # Cell aspect must match the chosen mode. + # Cell aspect must be exactly 4:3 (3:4 when rotated) to match + # the captured photos, within integer rounding of the layout. cw, ch = rects[0][2], rects[0][3] - if layout["mode"] == "portrait_ccw": - assert ch >= cw, f"portrait_ccw cell not taller than wide" - else: - assert cw >= ch, f"landscape_none cell not wider than tall" + want = 3 / 4 if layout["mode"] == "portrait_ccw" else 4 / 3 + assert abs(cw / ch - want) < 0.02, ( + f"pps={pps} spp={spp} {layout['mode']}: cell {cw}x{ch} " + f"aspect {cw / ch:.4f} != ~{want:.4f}") # Footer sits below the column, aligned in x, same width. for strip_i in range(spp): @@ -121,7 +122,11 @@ def check_layout_combos(): margin=120, photo_gutter=30, strip_gutter=30, auto_orient=True) from PIL import Image as _I - sheet = _I.open(out).convert("RGB") + with _I.open(out) as opened: + dpi = opened.info.get("dpi", (0, 0)) + assert abs(dpi[0] - 300) <= 1 and abs(dpi[1] - 300) <= 1, \ + f"sheet dpi metadata wrong: {dpi} (want ~300)" + sheet = opened.convert("RGB") assert sheet.size == (page_w, page_h) for i, r in enumerate(layout["photo_rects"]):