Skip to content

Portrait handling improvements#342

Draft
artyfarty wants to merge 5 commits into
theothernt:masterfrom
artyfarty:portrait-handling-improvements
Draft

Portrait handling improvements#342
artyfarty wants to merge 5 commits into
theothernt:masterfrom
artyfarty:portrait-handling-improvements

Conversation

@artyfarty
Copy link
Copy Markdown
Contributor

Three portrait-oriented UX improvements bundled in one branch because they share the same renderer plumbing and two of them share data flow:

  1. Add vertical Ken Burns pan as a portrait scale mode — new PhotoScale.KEN_BURNS_VERTICAL option alongside CENTER_CROP / FIT_CENTER. Portrait photos slowly pan top-to-bottom (or reverse) over the slot duration via ImagePlayerView matrix animation, revealing the full image
    instead of crop-to-center losing the top/bottom thirds. Landscape images with the mode selected fall back to center-crop.
  2. Face-aware center crop and Ken Burns pan for Immich portraits — opt-in toggle using Immich's ML face detection. Portrait + face-aware toggle:
    - CENTER_CROP: MATRIX-positioned so the face's y-center sits at the rule-of-thirds line.
    - KEN_BURNS_VERTICAL: pan range is narrowed so the largest face stays in view throughout, direction chosen so pan starts from whichever side the face lives. Configurable "face allowed off-screen percent at pan extremes" (0–40%, default 0 = strict, higher = wider more
    cinematic pan).
  3. Show previously-cached photo as loading splash — during the initial fetch, paint a JPEG snapshot of a recently-shown photo behind the loading UI instead of a black rectangle. Saves at most one snapshot per ~50 rendered photos to go easy on flash. No new preferences.

Why this branch is based on smart-slideshow (#341), not upstream/master

Commit 2 requires the clusterAlternates data model introduced by #341. When the Immich smart slideshow collapses a temporal burst into one slot, the cluster members now each need to carry their own face bbox (cluster gap can be up to 1 day, so alternates are often entirely
different scenes). This branch refactors clusterAlternates: List into List where each entry carries an optional NormalizedRect.

If #341 is merged first, the apparent size of this PR will shrink by those 2 commits. If there's a different preference — e.g. rebase commit 1 and 3 onto master since they don't depend on #341, and leave commit 2 stacked — happy to split.

Immich API quirk encountered

people[].faces[] is only populated by GET /api/assets/{id} in Immich 2.7.5. The batch /api/albums/{id} and /api/search/metadata endpoints return the field with an empty array even when withPeople: true or withFaces: true is passed. So enrichment makes one detail call per
portrait asset — parallelised, this adds ~1–3 s of startup time for ~900 portraits on LAN. Feature is opt-in to make the cost optional.

Face bounding boxes come from asset_face.imageWidth/imageHeight (the ML-preview frame Immich decoded on), not asset.width/height. Normalizing against the wrong dimensions gives consistently shifted boxes — documented inline.

Testing status

  • Commit 1 (Ken Burns pan) — tested live on an Nvidia Shield. Pan respects slideshow speed, pause/resume, skip. Landscape images unaffected.
  • Commit 2 (Face-aware) — tested live with Immich 2.7.5. Pan mathematically constrained to keep face in viewport at default 0% off-screen setting.
  • Commit 3 (Splash) — untested end-to-end yet (haven't had a startup with a cached file present). Logic review is clean; save path fires from ImagePlayerView.runSetupFinishedRunnable, load path fires from ScreenController.init before the media fetch. Fixes likely.

Expected adjustments before removing draft status

  • More live testing of splash on first reboot after warm-up.
  • Commit messages may get rewritten for upstream taste.
  • Would welcome maintainer input on whether splash should be a separate PR — I bundled because the implementation touches ImagePlayerView / ScreenController that the other two commits already modify, but it's functionally orthogonal.

artyfarty and others added 5 commits April 22, 2026 04:02
Temporal clustering of the Immich asset pool: sort by localDateTime,
group assets within a configurable gap, play one random representative
per cluster. Bursts and photo-heavy single events collapse to one slot
each so they don't dominate against long-span collection albums.

Albums matching an optional regex (e.g. film scans, where timestamps
are scanning-session artefacts) bypass clustering and are sampled by a
configurable keep-percent. Settings live under a new "Smart slideshow"
category in the Immich source screen; disabled by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the smart slideshow picked one representative per temporal
cluster at fetch time and froze that choice for the lifetime of the
playlist — across an entire session the same cluster always showed the
same photo, with the other members completely hidden until the next
provider refresh.

Now every cluster's other members travel with the representative on the
AerialMedia object (new `clusterAlternates: List<Uri>` field). When the
slot comes up for rendering, ImagePlayerView picks one of
{primary} ∪ {alternates} uniformly at random. Over repeated playlist
loops the user sees the whole cluster rotate through the same slot,
while the clustering's airtime-evening effect on album-vs-album mix is
preserved.

Attached metadata (album name, source pool, date, location) stays that
of the representative — cluster members are by definition within a
small time/location window so the numbers remain representative enough
for overlay captions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Portrait photos shown in a landscape viewport normally get center-cropped
and lose the top/bottom third of the image. This adds a third option to
the existing photo scale preference — a slow vertical pan that shows the
full image over the slideshow slot duration.

* New PhotoScale.KEN_BURNS_VERTICAL value (selectable from the existing
  Portrait / Landscape scale prefs; safely falls back to center-crop when
  applied to images that don't actually overflow vertically).
* ImagePlayerView sets scaleType MATRIX and animates imageMatrix Y
  translation from 0 to -(overflow) over the slot duration, alternating
  direction per slot for variety. Pause/resume respected via
  ValueAnimator.pause/resume.
* Works uniformly for all portrait assets — does not depend on any
  subject/face metadata. Landscape assets with this mode set fall back
  to center-crop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an opt-in "Face-aware portrait crop" toggle that uses Immich's
ML-detected face bounding boxes to bias how portrait photos are framed.

* New pref `photo_scale_face_aware` (off by default) in the existing
  Appearance → Scaling screen.
* `Asset` gains nullable `width/height/people` fields. `people` is only
  populated by `GET /api/assets/{id}` (in Immich 2.7.5 batch/search
  endpoints return an empty people array even when withPeople=true).
* `ImmichRepository.enrichPortraitsWithFaces(...)` parallel-fetches the
  per-asset detail endpoint for portrait-aspect assets and extracts the
  largest face's bounding box, normalized against the ML-preview frame
  (`asset_face.imageWidth/imageHeight` — NOT `asset.width/height`).
* `AerialMediaMetadata.subjectRect: NormalizedRect?` carries that rect
  through to the renderer.
* `ImagePlayerView.setScaleMode`, for portraits only:
  - CENTER_CROP + face → MATRIX with face's y-center at the rule-of-thirds
    line (~33 % from top of visible band).
  - KEN_BURNS_VERTICAL + face → pan range is narrowed so face + 7 % margin
    stays visible throughout, and pan direction starts from the side of
    the image where the face sits (upper-face → start at top pan down;
    lower-face → start at bottom pan up).
  - Face bigger than visible band → static center on face, no pan.
* Landscape and square images are untouched — face-aware code paths
  short-circuit on aspect mismatch, preserving existing behavior.

Cost: one `GET /api/assets/{id}` per portrait on each fetch cycle.
On LAN with 10 parallel requests this adds ~1-3 s to initial load for
a library of ~900 portraits. Feature is opt-in so users who don't want
the delay keep the old behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the screensaver starts up, a slideshow fetch can take several
seconds. Instead of a black rectangle behind the "Loading…" text, paint
a JPEG snapshot of a photo shown during a recent session.

* New SplashCache helper: saves/loads a single JPEG in the app's
  cacheDir (system may evict, which is fine — we just fall back to
  black). No schema, no DB, no migration.
* ImagePlayerView saves a snapshot every 50th rendered photo (~20 min
  at slideshow_speed=25s), overwriting the previous file. Gentle on
  flash; across sessions the captured photo naturally varies so users
  don't see the same splash every launch.
* ScreenController loads the file (if present) into the foreground
  ImageView before the media fetch is launched. The existing loading
  overlay sits on top; when the real first photo arrives via the
  normal load path, it replaces the splash seamlessly.

No new preferences — the feature is always on when a cached file exists.
Overlays/controls don't apply to the splash, matching the intent of a
pure background placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant