aspect ratio and keep awake

This commit is contained in:
2026-07-18 18:36:51 -04:00
parent 13b210935f
commit 755fa0632c
4 changed files with 100 additions and 13 deletions

View File

@ -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. Add `--windowed` to test in a window instead of fullscreen.
**Press Esc or Q to quit.** **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 ## Customizing
- **All settings** live in `config.toml` — camera choice, screen text, - **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 automatically after a successful print. (If the program is restarted
mid-cycle, photos here are counted so no progress is lost.) mid-cycle, photos here are counted so no progress is lost.)
- `contact_sheets/` — every printed sheet, kept as a backup in case of - `contact_sheets/` — every printed sheet, kept as a backup in case of
printer trouble. Delete them manually now and then, or set printer trouble. Sheets are full US-letter pages at 300 DPI, so they
`delete_contact_sheets = true` in the config to remove each one right also print at the right size from any other app. Delete them manually
after it prints. now and then, or set `delete_contact_sheets = true` in the config to
remove each one right after it prints.
## If printing fails ## If printing fails

View File

@ -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 a strip has few photos and a wide page fills better
(e.g. 1 photo per shoot, or one big photo). (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 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"]: for (x, y, w, h) in layout["footer_rects"]:
sheet.paste(footer_img, (x, y)) 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 return out_path
def print_sheet(path, printer=""): def print_sheet(path, printer=""):
"""Send the sheet to the printer via lp. Returns (ok, message).""" """Send the sheet to the printer via lp. Returns (ok, message).
cmd = ["lp", "-o", "media=letter", "-o", "fit-to-page"]
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: if printer:
cmd += ["-d", printer] cmd += ["-d", printer]
cmd.append(str(path)) cmd.append(str(path))

View File

@ -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 12 photos are composed onto a contact sheet and printed. Five minutes of
inactivity brings up the fish tank screensaver. 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. Run with --windowed for testing without taking over the display.
Esc or Q quits. Esc or Q quits.
""" """
import os
import shutil
import subprocess
import sys import sys
import time import time
import tomllib import tomllib
@ -26,6 +33,51 @@ ROOT = Path(__file__).resolve().parent
IDLE, PREVIEW, COUNTDOWN, FLASH, SCREENSAVER = range(5) 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(): def load_config():
with open(ROOT / "config.toml", "rb") as f: with open(ROOT / "config.toml", "rb") as f:
@ -290,6 +342,7 @@ class Booth:
def main(): def main():
reexec_keep_awake()
windowed = "--windowed" in sys.argv windowed = "--windowed" in sys.argv
booth = Booth(windowed=windowed) booth = Booth(windowed=windowed)
try: try:

View File

@ -92,12 +92,13 @@ def check_layout_combos():
assert ys == sorted(ys), f"pps={pps} spp={spp} strip {strip_i}: not top-to-bottom" assert ys == sorted(ys), f"pps={pps} spp={spp} strip {strip_i}: not top-to-bottom"
for a, b in zip(rects, rects[1:]): for a, b in zip(rects, rects[1:]):
assert b[1] >= a[1] + a[3], "photos overlap vertically" 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] cw, ch = rects[0][2], rects[0][3]
if layout["mode"] == "portrait_ccw": want = 3 / 4 if layout["mode"] == "portrait_ccw" else 4 / 3
assert ch >= cw, f"portrait_ccw cell not taller than wide" assert abs(cw / ch - want) < 0.02, (
else: f"pps={pps} spp={spp} {layout['mode']}: cell {cw}x{ch} "
assert cw >= ch, f"landscape_none cell not wider than tall" f"aspect {cw / ch:.4f} != ~{want:.4f}")
# Footer sits below the column, aligned in x, same width. # Footer sits below the column, aligned in x, same width.
for strip_i in range(spp): for strip_i in range(spp):
@ -121,7 +122,11 @@ def check_layout_combos():
margin=120, photo_gutter=30, strip_gutter=30, margin=120, photo_gutter=30, strip_gutter=30,
auto_orient=True) auto_orient=True)
from PIL import Image as _I 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) assert sheet.size == (page_w, page_h)
for i, r in enumerate(layout["photo_rects"]): for i, r in enumerate(layout["photo_rects"]):