Calendar mode polish batch + Today & Tomorrow view

Responds to post-launch feedback on calendar mode: configurable
week-start day for week/month views, crisper non-antialiased text
(threshold-masked instead of drawn straight, so Floyd-Steinberg
dithering doesn't speckle glyph edges), a color-coded/proportionally
filled battery icon on the manage overlay, word-wrapped placeholder
text so "Calendar isn't set up yet" no longer clips in portrait, photo
inlay support extended from agenda-only to every view, and a fix so
manage-overlay face labels reposition correctly when a photo inlay is
active (they previously assumed the photo filled the whole canvas).

Also adds a fourth calendar view, "Today & Tomorrow" -- a two-day
agenda that reuses the same per-day row-layout helper the single-day
agenda view already has.
This commit is contained in:
2026-07-22 21:20:44 -04:00
parent 716776c3f2
commit aa194be09a
9 changed files with 326 additions and 115 deletions
+163 -77
View File
@@ -22,11 +22,14 @@ from .image_pipeline import (
_quantize, _quantize,
_transpose_and_pack, _transpose_and_pack,
compose_into, compose_into,
draw_text,
logical_render_size, logical_render_size,
) )
CALENDAR_VIEWS = ["agenda", "week", "month"] CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "week": "Week", "month": "Month"} CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
"week": "Week", "month": "Month"}
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
MARGIN = 20 MARGIN = 20
BG = (255, 255, 255) BG = (255, 255, 255)
@@ -86,7 +89,9 @@ def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.Ima
"""Pixel-width-aware truncation (unlike device.py's char-count """Pixel-width-aware truncation (unlike device.py's char-count
_truncate, tuned for a fixed firmware font at a fixed size) -- this _truncate, tuned for a fixed firmware font at a fixed size) -- this
module draws at several different sizes, so truncation has to module draws at several different sizes, so truncation has to
measure the actual font/size in play.""" measure the actual font/size in play. Still uses `draw.textlength`
for measurement (identical metrics to draw_text's own bbox), just
doesn't paint anything."""
if draw.textlength(text, font=font) <= max_width: if draw.textlength(text, font=font) <= max_width:
return text return text
ellipsis = "..." ellipsis = "..."
@@ -100,27 +105,84 @@ def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.Ima
return text[:lo] + ellipsis if lo else ellipsis return text[:lo] + ellipsis if lo else ellipsis
# --- Photo inlay region, shared by every view --------------------------
def inlay_region(orientation: str) -> tuple[int, int, int, int]:
"""The photo-inlay's (x0, y0, w, h) within the full logical canvas --
the long-axis half (left in landscape, top in portrait), same
proportion for every view so switching views doesn't reflow the
photo. Also used by routers/common.py's build_manage_content to
correctly reposition manage-overlay face labels when a photo inlay
is active (they'd otherwise be computed as if the photo filled the
whole panel)."""
logical_w, logical_h = logical_render_size(orientation)
if logical_w >= logical_h:
return 0, 0, logical_w // 2, logical_h
return 0, 0, logical_w, logical_h // 2
def _content_region(orientation: str, has_inlay: bool) -> tuple[int, int, int, int]:
"""The remaining (x0, y0, w, h) a view's own content (list/grid)
draws into -- the whole canvas normally, or whatever inlay_region()
didn't claim."""
logical_w, logical_h = logical_render_size(orientation)
if not has_inlay:
return 0, 0, logical_w, logical_h
ix0, iy0, iw, ih = inlay_region(orientation)
if logical_w >= logical_h:
return iw, 0, logical_w - iw, logical_h
return 0, ih, logical_w, logical_h - ih
def _paste_inlay(img: Image.Image, photo_inlay: Image.Image, orientation: str) -> None:
x0, y0, w, h = inlay_region(orientation)
photo = compose_into(photo_inlay, None, w, h, "crop_fill")
img.paste(photo, (x0, y0))
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
body_font: ImageFont.ImageFont, owners_seen: list[str]) -> None:
"""Draws one day's header + event rows within `region` (x0, y0, w, h)
-- factored out of _build_agenda so the today-and-tomorrow view
(_build_today_tomorrow) can stack two of these vertically without
duplicating the row-layout/truncation logic."""
x0, y0, w, h = region
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
text_w = w - MARGIN * 2
header = day.strftime("%A, %B ") + str(day.day)
draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), title_font)
y = text_y0 + title_font.size + 12
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
day_events = _events_on_day(events, day, tz)
row_h = body_font.size + 14
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not day_events:
draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED)
for i, event in enumerate(day_events):
if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
break
color = _owner_color(event["owner_display_name"], owners_seen)
draw.rectangle([text_x0, y + 3, text_x0 + 6, y + row_h - 8], fill=color)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
line = f"{time_str} {event['summary']}"
draw_text(img, (text_x0 + 16, y), _truncate_to_width(draw, line, body_font, text_w - 16), body_font)
y += row_h
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None) -> Image.Image: photo_inlay: Image.Image | None) -> Image.Image:
logical_w, logical_h = logical_render_size(orientation) logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG) img = Image.new("RGB", (logical_w, logical_h), BG)
text_x0 = MARGIN
text_w = logical_w - MARGIN * 2
if photo_inlay is not None: if photo_inlay is not None:
# Long axis split: landscape splits left/right, portrait top/bottom. _paste_inlay(img, photo_inlay, orientation)
if logical_w >= logical_h: cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
photo_w = logical_w // 2
photo = compose_into(photo_inlay, None, photo_w, logical_h, "crop_fill")
img.paste(photo, (0, 0))
text_x0 = photo_w + MARGIN
text_w = logical_w - photo_w - MARGIN * 2
else:
photo_h = logical_h // 2
photo = compose_into(photo_inlay, None, logical_w, photo_h, "crop_fill")
img.paste(photo, (0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
# Smaller title when the inlay halves the available width -- "Wednesday, # Smaller title when the inlay halves the available width -- "Wednesday,
# July 22" at full size doesn't fit ~360px, and a *narrower* column is # July 22" at full size doesn't fit ~360px, and a *narrower* column is
# exactly when a smaller font (rather than truncating to "Wednesday...") # exactly when a smaller font (rather than truncating to "Wednesday...")
@@ -128,109 +190,128 @@ def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz:
title_font = ImageFont.load_default(size=34 if photo_inlay is None else 24) title_font = ImageFont.load_default(size=34 if photo_inlay is None else 24)
body_font = ImageFont.load_default(size=22) body_font = ImageFont.load_default(size=22)
text_y0 = MARGIN if photo_inlay is None or logical_w >= logical_h else logical_h // 2 + MARGIN
day = datetime.now(tz).date() + timedelta(days=browse_offset) day = datetime.now(tz).date() + timedelta(days=browse_offset)
header = day.strftime("%A, %B ") + str(day.day)
draw.text((text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), fill=FG, font=title_font)
y = text_y0 + title_font.size + 12
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
day_events = _events_on_day(events, day, tz)
owners_seen: list[str] = [] owners_seen: list[str] = []
row_h = body_font.size + 14 _draw_agenda_day(img, draw, day, events, tz, (cx0, cy0, cw, ch), title_font, body_font, owners_seen)
max_rows = max(0, (logical_h - MARGIN - y) // row_h)
if not day_events:
draw.text((text_x0, y), "Nothing scheduled", fill=MUTED, font=body_font)
for i, event in enumerate(day_events):
if i >= max_rows:
draw.text((text_x0, y), f"+{len(day_events) - max_rows} more", fill=MUTED, font=body_font)
break
color = _owner_color(event["owner_display_name"], owners_seen)
draw.rectangle([text_x0, y + 3, text_x0 + 6, y + row_h - 8], fill=color)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
line = f"{time_str} {event['summary']}"
draw.text((text_x0 + 16, y), _truncate_to_width(draw, line, body_font, text_w - 16), fill=FG, font=body_font)
y += row_h
return img return img
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo) -> Image.Image: def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None) -> Image.Image:
"""Two _draw_agenda_day sections stacked vertically within the content
region (below each other rather than side-by-side -- narrower than
tall doesn't leave enough width per day for the event-row text once
an inlay's already claimed half the canvas). browse_offset shifts
the whole two-day window together, same "days" unit _build_agenda
already uses, so NEXT/BACK behaves identically across both views."""
logical_w, logical_h = logical_render_size(orientation) logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG) img = Image.new("RGB", (logical_w, logical_h), BG)
if photo_inlay is not None:
_paste_inlay(img, photo_inlay, orientation)
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
header_font = ImageFont.load_default(size=18) title_font = ImageFont.load_default(size=26 if photo_inlay is None else 20)
chip_font = ImageFont.load_default(size=14) body_font = ImageFont.load_default(size=18 if photo_inlay is None else 15)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = ch // 2
owners_seen: list[str] = []
for i in range(2):
section_y0 = cy0 + i * section_h
if i > 0:
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen)
return img
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None, week_start: int) -> Image.Image:
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
if photo_inlay is not None:
_paste_inlay(img, photo_inlay, orientation)
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
draw = ImageDraw.Draw(img)
header_font = ImageFont.load_default(size=18 if photo_inlay is None else 14)
chip_font = ImageFont.load_default(size=14 if photo_inlay is None else 12)
today = datetime.now(tz).date() today = datetime.now(tz).date()
week_start = today - timedelta(days=today.weekday()) + timedelta(weeks=browse_offset) days_since_start = (today.weekday() - week_start) % 7
col_w = (logical_w - MARGIN * 2) // 7 week_first_day = today - timedelta(days=days_since_start) + timedelta(weeks=browse_offset)
col_w = (cw - MARGIN * 2) // 7
header_h = 44 header_h = 44
owners_seen: list[str] = [] owners_seen: list[str] = []
for col in range(7): for col in range(7):
day = week_start + timedelta(days=col) day = week_first_day + timedelta(days=col)
x0 = MARGIN + col * col_w x0 = cx0 + MARGIN + col * col_w
if col > 0: if col > 0:
draw.line([(x0, MARGIN), (x0, logical_h - MARGIN)], fill=RULE) draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}" label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
draw.text((x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), fill=FG, font=header_font) draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
y = MARGIN + header_h y = cy0 + MARGIN + header_h
row_h = chip_font.size + 10 row_h = chip_font.size + 10
max_rows = max(0, (logical_h - MARGIN - y) // row_h) max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz) day_events = _events_on_day(events, day, tz)
for i, event in enumerate(day_events): for i, event in enumerate(day_events):
if i >= max_rows: if i >= max_rows:
draw.text((x0 + 6, y), f"+{len(day_events) - max_rows}", fill=MUTED, font=chip_font) draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED)
break break
color = _owner_color(event["owner_display_name"], owners_seen) color = _owner_color(event["owner_display_name"], owners_seen)
draw.rectangle([x0 + 4, y + 2, x0 + 8, y + row_h - 6], fill=color) draw.rectangle([x0 + 4, y + 2, x0 + 8, y + row_h - 6], fill=color)
text = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}" text = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
draw.text((x0 + 14, y), _truncate_to_width(draw, text, chip_font, col_w - 18), fill=FG, font=chip_font) draw_text(img, (x0 + 14, y), _truncate_to_width(draw, text, chip_font, col_w - 18), chip_font)
y += row_h y += row_h
return img return img
def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo) -> Image.Image: def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None, week_start: int) -> Image.Image:
"""Density dots per day, not literal event text -- real text at """Density dots per day, not literal event text -- real text at
typical month-cell size (~100x70px) is close to unreadable on a typical month-cell size (~100x70px) is close to unreadable on a
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.""" 6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
logical_w, logical_h = logical_render_size(orientation) logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG) img = Image.new("RGB", (logical_w, logical_h), BG)
if photo_inlay is not None:
_paste_inlay(img, photo_inlay, orientation)
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
header_font = ImageFont.load_default(size=16) header_font = ImageFont.load_default(size=16 if photo_inlay is None else 12)
day_font = ImageFont.load_default(size=18) day_font = ImageFont.load_default(size=18 if photo_inlay is None else 13)
today = datetime.now(tz).date() today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset) target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks = list(calendar_module.Calendar(firstweekday=0).monthdatescalendar(target_month.year, target_month.month)) weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
col_w = (logical_w - MARGIN * 2) // 7 col_w = (cw - MARGIN * 2) // 7
header_h = 28 header_h = 28
grid_top = MARGIN + header_h grid_top = cy0 + MARGIN + header_h
row_h = (logical_h - MARGIN - grid_top) // len(weeks) row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks)
for col, name in enumerate(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]): day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
draw.text((MARGIN + col * col_w + 6, MARGIN), name, fill=MUTED, font=header_font) for col, name in enumerate(day_names):
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font, MUTED)
owners_seen: list[str] = [] owners_seen: list[str] = []
dot_r = 4 dot_r = 4
for row, week in enumerate(weeks): for row, week in enumerate(weeks):
for col, day in enumerate(week): for col, day in enumerate(week):
x0 = MARGIN + col * col_w x0 = cx0 + MARGIN + col * col_w
y0 = grid_top + row * row_h y0 = grid_top + row * row_h
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE) draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
in_month = day.month == target_month.month in_month = day.month == target_month.month
color = FG if in_month else MUTED color = FG if in_month else MUTED
if day == today: if day == today:
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG) draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
draw.text((x0 + 6, y0 + 4), str(day.day), fill=color, font=day_font) draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, color)
day_events = _events_on_day(events, day, tz) day_events = _events_on_day(events, day, tz)
dot_x = x0 + 8 dot_x = x0 + 8
@@ -240,39 +321,44 @@ def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: Z
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=color) draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=color)
dot_x += dot_r * 2 + 4 dot_x += dot_r * 2 + 4
if len(day_events) > 4: if len(day_events) > 4:
draw.text((dot_x, dot_y - 4), f"+{len(day_events) - 4}", fill=MUTED, font=header_font) draw_text(img, (dot_x, dot_y - 4), f"+{len(day_events) - 4}", header_font, MUTED)
return img return img
_BUILDERS = {"agenda": _build_agenda, "week": _build_week, "month": _build_month} _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "week": _build_week,
"month": _build_month}
def _build(events: list[dict], view: str, browse_offset: int, orientation: str, timezone: str, def _build(events: list[dict], view: str, browse_offset: int, orientation: str, timezone: str,
photo_inlay: Image.Image | None, fetch_summary: str) -> Image.Image: photo_inlay: Image.Image | None, fetch_summary: str, week_start: int) -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC") tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
builder = _BUILDERS.get(view, _build_agenda) if view == "agenda":
if builder is _build_agenda:
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay) img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay)
elif view == "today_tomorrow":
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay)
elif view == "week":
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start)
elif view == "month":
img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start)
else: else:
img = builder(events, browse_offset, orientation, tz) img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay)
if fetch_summary: if fetch_summary:
draw = ImageDraw.Draw(img)
font = ImageFont.load_default(size=14) font = ImageFont.load_default(size=14)
logical_w, logical_h = img.size logical_w, logical_h = img.size
draw.text((MARGIN, logical_h - MARGIN - font.size), fetch_summary, fill=MUTED, font=font) draw_text(img, (MARGIN, logical_h - MARGIN - font.size), fetch_summary, font, MUTED)
return img return img
def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str, def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str,
palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None, palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None,
fetch_summary: str = "", manage: dict | None = None) -> bytes: fetch_summary: str = "", manage: dict | None = None, week_start: int = 0) -> bytes:
"""Renders one of CALENDAR_VIEWS to the panel's packed format. Always """Renders one of CALENDAR_VIEWS to the panel's packed format. Always
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every
other renderer honors.""" other renderer honors."""
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary) img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start)
img = _apply_manage_overlay(img, manage) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation) return _transpose_and_pack(quantized, orientation)
@@ -280,11 +366,11 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati
def render_calendar_preview_png(events: list[dict], view: str, browse_offset: int, orientation: str, def render_calendar_preview_png(events: list[dict], view: str, browse_offset: int, orientation: str,
palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None, palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None,
fetch_summary: str = "", manage: dict | None = None) -> bytes: fetch_summary: str = "", manage: dict | None = None, week_start: int = 0) -> bytes:
"""Same pipeline as render_calendar, but a normal browser-viewable """Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame.""" image_pipeline.render_preview_png's relationship to render_frame."""
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary) img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start)
img = _apply_manage_overlay(img, manage) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO() buf = io.BytesIO()
+26 -8
View File
@@ -25,7 +25,7 @@ MAX_LABELED_FACES = 6
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str, def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
orientation: str = "landscape") -> list[dict]: orientation: str = "landscape", region: tuple[int, int, int, int] | None = None) -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in """Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
logical (pre-rotation) frame space at each named face's bottom-center logical (pre-rotation) frame space at each named face's bottom-center
point -- manage_overlay.compose() draws these directly onto the point -- manage_overlay.compose() draws these directly onto the
@@ -38,6 +38,15 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
match the settings that were active then -- otherwise the placement match the settings that were active then -- otherwise the placement
computed here won't match what's actually on screen. computed here won't match what's actually on screen.
`region` is (x0, y0, w, h): where in the logical canvas the photo
actually landed, if not the whole thing -- e.g. calendar mode's
agenda photo-inlay only occupies half the panel (see
calendar_render.inlay_region), and without this a label would be
placed as if the photo filled the entire canvas, landing well off
where the inlaid photo actually is. None (the default) means the
photo fills the whole logical canvas, matching every other caller
(photos mode always renders full-panel).
The placement math matches render_frame()'s own composition step The placement math matches render_frame()'s own composition step
exactly (see image_pipeline._placement_transform, shared so the two exactly (see image_pipeline._placement_transform, shared so the two
can't drift apart). can't drift apart).
@@ -46,11 +55,16 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
if not named: if not named:
return [] return []
logical_w, logical_h = logical_render_size(orientation) if region is None:
logical_w, logical_h = logical_render_size(orientation)
region_x0, region_y0, target_w, target_h = 0, 0, logical_w, logical_h
else:
region_x0, region_y0, target_w, target_h = region
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB")) fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
scale_x, scale_y, offset_x, offset_y = _placement_transform( scale_x, scale_y, offset_x, offset_y = _placement_transform(
fitted.width, fitted.height, logical_w, logical_h, display_mode, faces fitted.width, fitted.height, target_w, target_h, display_mode, faces
) )
labels = [] labels = []
@@ -65,12 +79,16 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
bottom_y = face["boundingBoxY2"] * img_scale_y bottom_y = face["boundingBoxY2"] * img_scale_y
frame_x = center_x * scale_x + offset_x # Relative to the region's own origin first (matches
frame_y = bottom_y * scale_y + offset_y # _placement_transform's target_w/target_h space), then shifted
# into full-canvas coordinates.
region_x = center_x * scale_x + offset_x
region_y = bottom_y * scale_y + offset_y
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h): if not (0 <= region_x <= target_w and 0 <= region_y <= target_h):
continue # this face got cropped out of the final frame entirely continue # this face got cropped out of the region entirely
labels.append({"name": face["person"]["name"], "x": int(frame_x), "y": int(frame_y)}) labels.append({"name": face["person"]["name"],
"x": int(region_x + region_x0), "y": int(region_y + region_y0)})
return labels return labels
+50 -7
View File
@@ -4,11 +4,34 @@ from __future__ import annotations
import io import io
from PIL import Image, ImageEnhance, ImageOps from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
EPD_WIDTH = 800 EPD_WIDTH = 800
EPD_HEIGHT = 480 EPD_HEIGHT = 480
# PIL's TrueType rendering antialiases by default (graduated gray edge
# pixels). Those survive straight into _quantize's Floyd-Steinberg
# dithering, which -- confirmed visually -- turns them into scattered
# colored speckles along every glyph edge once forced onto the panel's 6
# colors, since a mid-gray input has no close palette match and the
# diffused error bounces between whichever colors are nearest. Drawing
# through a thresholded bilevel mask instead keeps every edge pure
# black/white, which _quantize then reproduces exactly (both are already
# palette colors, nothing to dither). Shared by every module that draws
# text before quantization (this file's render_placeholder,
# calendar_render.py, manage_overlay.py).
_TEXT_MASK_THRESHOLD = 110
def draw_text(img: Image.Image, xy: tuple[int, int], text: str, font: ImageFont.ImageFont,
fill: tuple[int, int, int] = (0, 0, 0)) -> None:
bbox = font.getbbox(text)
w, h = max(1, bbox[2] - bbox[0]), max(1, bbox[3] - bbox[1])
mask = Image.new("L", (w, h), 0)
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), text, fill=255, font=font)
mask = mask.point(lambda p: 255 if p > _TEXT_MASK_THRESHOLD else 0)
img.paste(fill, (xy[0] + bbox[0], xy[1] + bbox[1]), mask)
# How each orientation maps the logically-composed image onto the native # How each orientation maps the logically-composed image onto the native
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the # 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
# crop ratio matches how the frame actually hangs) and rotate into native # crop ratio matches how the frame actually hangs) and rotate into native
@@ -387,14 +410,14 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
`manage`, same as render_frame's -- lets the manage button still work `manage`, same as render_frame's -- lets the manage button still work
(at minimum, the scan-to-manage QR) on a frame that isn't configured (at minimum, the scan-to-manage QR) on a frame that isn't configured
yet.""" yet."""
from PIL import ImageDraw, ImageFont margin = 24
logical_w, logical_h = logical_render_size(orientation) logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255)) img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img) # measurement only (textbbox/textlength) -- painting goes through draw_text
title_font = ImageFont.load_default(size=34) title_font = ImageFont.load_default(size=34)
body_font = ImageFont.load_default(size=24) body_font = ImageFont.load_default(size=24)
max_text_w = logical_w - margin * 2
qr_img = None qr_img = None
if qr_url: if qr_url:
@@ -409,19 +432,39 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
scale = max(1, target // raw.width) scale = max(1, target // raw.width)
qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST) qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
# Word-wrap each input line to the panel's actual width (portrait is
# much narrower than landscape -- a line written assuming ~800px
# would otherwise run straight off the edge) before laying anything
# out, so wrapped sub-lines count toward the vertical centering below.
def wrap(text: str, font) -> list[str]:
words = text.split()
if not words:
return [text]
out, current = [], words[0]
for word in words[1:]:
candidate = f"{current} {word}"
if draw.textlength(candidate, font=font) <= max_text_w:
current = candidate
else:
out.append(current)
current = word
out.append(current)
return out
# Vertical layout: text block, then QR under it, centered as a group. # Vertical layout: text block, then QR under it, centered as a group.
line_heights = [] line_heights = []
for i, line in enumerate(lines): for i, line in enumerate(lines):
font = title_font if i == 0 else body_font font = title_font if i == 0 else body_font
bbox = draw.textbbox((0, 0), line, font=font) for sub_line in wrap(line, font):
line_heights.append((line, font, bbox[2] - bbox[0], bbox[3] - bbox[1])) bbox = draw.textbbox((0, 0), sub_line, font=font)
line_heights.append((sub_line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
gap = 14 gap = 14
text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0) text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0)
total_h = text_h + (qr_img.height + 28 if qr_img else 0) total_h = text_h + (qr_img.height + 28 if qr_img else 0)
y = max(20, (logical_h - total_h) // 2) y = max(20, (logical_h - total_h) // 2)
for line, font, w, h in line_heights: for line, font, w, h in line_heights:
draw.text(((logical_w - w) // 2, y), line, fill=(0, 0, 0), font=font) draw_text(img, ((logical_w - w) // 2, y), line, font)
y += h + gap y += h + gap
if qr_img: if qr_img:
+39 -11
View File
@@ -14,6 +14,8 @@ from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import DEFAULT_PALETTE_RGB, draw_text
PADDING = 16 PADDING = 16
QR_TEXT_GAP = 8 QR_TEXT_GAP = 8
LINE_GAP = 4 LINE_GAP = 4
@@ -62,13 +64,13 @@ def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.Image
return w, h return w, h
def _draw_centered_lines(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont, def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
center_x: int, top: int) -> None: center_x: int, top: int) -> None:
y = top y = top
for line in lines: for line in lines:
bbox = draw.textbbox((0, 0), line, font=font) bbox = draw.textbbox((0, 0), line, font=font)
w = bbox[2] - bbox[0] w = bbox[2] - bbox[0]
draw.text((center_x - w // 2, y), line, fill=(0, 0, 0), font=font) draw_text(img, (center_x - w // 2, y), line, font)
y += (bbox[3] - bbox[1]) + LINE_GAP y += (bbox[3] - bbox[1]) + LINE_GAP
@@ -92,7 +94,7 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption:
center_x = x0 + w // 2 center_x = x0 + w // 2
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING)) img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
if caption: if caption:
_draw_centered_lines(draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP) _draw_centered_lines(img, draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
return x0, y0, w, h return x0, y0, w, h
@@ -106,7 +108,7 @@ def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str]
x0, y0 = _corner_origin(img.size, (w, h), corner) x0, y0 = _corner_origin(img.size, (w, h), corner)
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0)) draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
_draw_centered_lines(draw, lines, font, x0 + w // 2, y0 + PADDING) _draw_centered_lines(img, draw, lines, font, x0 + w // 2, y0 + PADDING)
def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: str) -> tuple[int, int]: def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: str) -> tuple[int, int]:
@@ -121,12 +123,33 @@ def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner:
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
# DEFAULT_PALETTE_RGB order is [BLACK, WHITE, YELLOW, RED, BLUE, GREEN]
# (see image_pipeline.PANEL_CODES) -- picked by level so the fill itself
# carries the "how worried should I be" signal, not just the number next
# to it. Thresholds match the low-battery-alert spirit elsewhere in this
# project (not tied to a frame's own configured alert threshold, since
# this glyph has to make sense with no configuration at all).
_BATTERY_LOW = DEFAULT_PALETTE_RGB[3] # red
_BATTERY_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
_BATTERY_HIGH = DEFAULT_PALETTE_RGB[5] # green
def _battery_fill_color(percent: int) -> tuple[int, int, int]:
if percent <= 15:
return _BATTERY_LOW
if percent <= 40:
return _BATTERY_MEDIUM
return _BATTERY_HIGH
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int, def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
anchor_w: int, anchor_h: int) -> None: anchor_w: int, anchor_h: int) -> None:
"""Battery glyph + "NN%" text, right-aligned under the given anchor """Battery glyph (now actually filled to `percent`, not just a static
box (the manage QR box) -- a sensible default position, not a outline -- easy now that this renders server-side instead of being a
constraint anything else has to route around; move this call site's fixed bitmap firmware drew) + "NN%" text, right-aligned under the
arguments to place it anywhere else instead.""" given anchor box (the manage QR box) -- a sensible default position,
not a constraint anything else has to route around; move this call
site's arguments to place it anywhere else instead."""
font = _font(BODY_FONT_SIZE) font = _font(BODY_FONT_SIZE)
text = f"{percent}%" text = f"{percent}%"
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
@@ -143,13 +166,18 @@ def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anc
icon_x = x0 + PADDING icon_x = x0 + PADDING
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2 icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
inner_x0, inner_y0 = icon_x + BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_STROKE
inner_x1, inner_y1 = icon_x + BATTERY_ICON_W - BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_H - BATTERY_ICON_STROKE
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (percent / 100))
if fill_x1 > inner_x0:
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_battery_fill_color(percent))
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0), draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
width=BATTERY_ICON_STROKE) width=BATTERY_ICON_STROKE)
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2 nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H], draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H],
fill=(0, 0, 0)) fill=(0, 0, 0))
draw.text((icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2), draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
text, fill=(0, 0, 0), font=font) text, font)
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None: def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
@@ -174,7 +202,7 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc
y0 = max(0, min(y0, img_h - h)) y0 = max(0, min(y0, img_h - h))
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0)) draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
draw.text((x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, fill=(0, 0, 0), font=font) draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font)
def compose(image: Image.Image, management_url: str, battery_percent: int | None = None, def compose(image: Image.Image, management_url: str, battery_percent: int | None = None,
+11
View File
@@ -100,6 +100,16 @@ def _migration_7(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''"))
def _migration_8(conn) -> None:
"""Configurable week-start day for calendar mode's week/month views
(0=Monday..6=Sunday, matching Python's date.weekday()/calendar.Calendar
convention exactly -- no translation needed at render time). Default 0
(Monday) matches calendar_render.py's previous hardcoded behavior, so
this is a no-op for every existing frame until changed."""
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start INTEGER NOT NULL DEFAULT 0"))
MIGRATIONS = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -108,6 +118,7 @@ MIGRATIONS = [
(5, _migration_5), (5, _migration_5),
(6, _migration_6), (6, _migration_6),
(7, _migration_7), (7, _migration_7),
(8, _migration_8),
] ]
+3
View File
@@ -148,6 +148,9 @@ class Frame(Base):
# -- calendar mode (see calendar_feed.py, calendar_render.py, # -- calendar mode (see calendar_feed.py, calendar_render.py,
# routers/device.py's RENDERERS["calendar"]) -- # routers/device.py's RENDERERS["calendar"]) --
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month" calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
# 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) --
# which day week/month views start their grid on.
calendar_week_start: Mapped[int] = mapped_column(Integer, default=0)
# Agenda view only; reuses this frame's existing photos-mode album/ # Agenda view only; reuses this frame's existing photos-mode album/
# queue, not a separate photo setup. # queue, not a separate photo setup.
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False) calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
+7 -3
View File
@@ -99,6 +99,7 @@ def api_config_save(
mode: str | None = Form(None), mode: str | None = Form(None),
calendar_view: str | None = Form(None), calendar_view: str | None = Form(None),
calendar_photo_inlay: bool | None = Form(None), calendar_photo_inlay: bool | None = Form(None),
calendar_week_start: int | None = Form(None),
frame: Frame = Depends(require_frame_control), frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -174,6 +175,8 @@ def api_config_save(
cfg.calendar_view = new_view cfg.calendar_view = new_view
if calendar_photo_inlay is not None: if calendar_photo_inlay is not None:
cfg.calendar_photo_inlay = calendar_photo_inlay cfg.calendar_photo_inlay = calendar_photo_inlay
if calendar_week_start is not None:
cfg.calendar_week_start = max(0, min(6, calendar_week_start))
cfg.stats_config_saves += 1 cfg.stats_config_saves += 1
return {"status": "saved"} return {"status": "saved"}
@@ -436,13 +439,13 @@ def api_calendar_included(
def _calendar_photo_inlay(frame: Frame, db: Session): def _calendar_photo_inlay(frame: Frame, db: Session):
"""The agenda view's optional photo-inlay source image, or None if """The photo-inlay's source image (any view now, not just agenda), or
inlay is off, not agenda view, or the frame's photos-mode album isn't None if inlay is off or the frame's photos-mode album isn't
configured. Shared shape between the live render (routers/device.py's configured. Shared shape between the live render (routers/device.py's
_render_calendar_mode) and this preview endpoint; small enough that _render_calendar_mode) and this preview endpoint; small enough that
duplicating rather than factoring out is fine, since the two call duplicating rather than factoring out is fine, since the two call
sites differ slightly in error handling.""" sites differ slightly in error handling."""
if not (frame.calendar_view == "agenda" and frame.calendar_photo_inlay): if not frame.calendar_photo_inlay:
return None return None
url, key = immich_creds(frame) url, key = immich_creds(frame)
if not (url and key and frame.album_id): if not (url and key and frame.album_id):
@@ -477,6 +480,7 @@ def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session
png = calendar_render.render_calendar_preview_png( png = calendar_render.render_calendar_preview_png(
events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation, events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary, palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
week_start=frame.calendar_week_start,
) )
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
+24 -7
View File
@@ -244,15 +244,29 @@ def _format_taken_at(exif: dict) -> str | None:
def _manage_content_asset_id(frame: Frame) -> str | None: def _manage_content_asset_id(frame: Frame) -> str | None:
"""Whether frame.current_asset_id refers to a photo actually visible """Whether frame.current_asset_id refers to a photo actually visible
right now, for whichever mode is active -- always true in photos right now, for whichever mode is active -- always true in photos
mode; only true in calendar mode when the agenda view's photo inlay mode; only true in calendar mode when that view's photo inlay is on
is on (otherwise current_asset_id could be stale, left over from (otherwise current_asset_id could be stale, left over from whenever
whenever photos mode last ran, and showing its location/date/share photos mode last ran, and showing its location/date/share info on a
info on a manage overlay over a view with no visible photo at all manage overlay over a view with no visible photo at all would be
would be actively misleading, not just unhelpful).""" actively misleading, not just unhelpful)."""
relevant = frame.mode != "calendar" or (frame.calendar_view == "agenda" and frame.calendar_photo_inlay) relevant = frame.mode != "calendar" or frame.calendar_photo_inlay
return frame.current_asset_id if relevant and frame.current_asset_id else None return frame.current_asset_id if relevant and frame.current_asset_id else None
def _manage_content_region(frame: Frame) -> tuple[int, int, int, int] | None:
"""Where the photo behind _manage_content_asset_id actually landed in
the logical canvas -- None (the whole canvas) in photos mode, or
calendar_render.inlay_region(...) when a calendar view's photo inlay
is what's showing. Needed so face labels (and, if ever added, other
photo-relative overlay positioning) land on the actual inlaid photo
instead of where a full-panel photo would have been."""
if frame.mode == "calendar" and frame.calendar_photo_inlay:
from ..calendar_render import inlay_region
return inlay_region(frame.orientation)
return None
def build_manage_content(db: Session, frame: Frame, request) -> dict: def build_manage_content(db: Session, frame: Frame, request) -> dict:
"""Gathers everything manage_overlay.compose() needs -- what used to """Gathers everything manage_overlay.compose() needs -- what used to
be two separate device-facing endpoints (/frame/photo-info, be two separate device-facing endpoints (/frame/photo-info,
@@ -293,7 +307,10 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
preview_bytes = client.download_asset_preview(asset_id) preview_bytes = client.download_asset_preview(asset_id)
from ..face_labels import compute_face_labels from ..face_labels import compute_face_labels
content["face_labels"] = compute_face_labels(preview_bytes, faces, frame.display_mode, frame.orientation) content["face_labels"] = compute_face_labels(
preview_bytes, faces, frame.display_mode, frame.orientation,
region=_manage_content_region(frame),
)
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e) logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
+3 -2
View File
@@ -144,7 +144,8 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d
locked.calendar_browse_offset = 0 locked.calendar_browse_offset = 0
browse_offset = locked.calendar_browse_offset browse_offset = locked.calendar_browse_offset
view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda" view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
inlay_wanted = locked.calendar_photo_inlay and view == "agenda" week_start = locked.calendar_week_start
inlay_wanted = locked.calendar_photo_inlay
events, summary = get_or_refresh_calendar_events(db, frame) events, summary = get_or_refresh_calendar_events(db, frame)
@@ -169,7 +170,7 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d
return calendar_render.render_calendar( return calendar_render.render_calendar(
events, view=view, browse_offset=browse_offset, orientation=frame.orientation, events, view=view, browse_offset=browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, palette_rgb=frame.palette_rgb, timezone=frame.timezone,
photo_inlay=photo_inlay, fetch_summary=summary, manage=manage, photo_inlay=photo_inlay, fetch_summary=summary, manage=manage, week_start=week_start,
) )