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.
|
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
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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"]):
|
||||||
|
|||||||
Reference in New Issue
Block a user