aspect ratio and keep awake
This commit is contained in:
22
README.md
22
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
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"]):
|
||||
|
||||
Reference in New Issue
Block a user