Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/features/smart-playlists.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Determinism matters: the same input set always produces the same listening order

- **Image source priority** — try the top 3 artists' Deezer pictures first (shared `metadata_artwork/<hash>.jpg` cache, looks best because portraits crop cleanly). If none of the cluster's artists are Deezer-enriched (common with niche / soundtrack libraries), fall back to **album artwork of the first 3 shuffled tracks** from the per-profile cache (`<root>/profiles/<id>/artwork/<hash>.<format>`). The fallback is what guarantees a real cover even for libraries dominated by obscure artists.
- **Layout auto-pick** — the `build_composite_cover` entry point dispatches by input count: 1 → fill the canvas, 2 → vertical halves, 3 → 3 strips (the Daily Mix look), 4+ → **2×2 grid** (Spotify-style auto-playlist cover, used by the user-playlist auto-cover pipeline; smart playlists never reach this branch since they cap at 3 artist pictures).
- **Identical-input dedup** — the compositor dedupes incoming paths before counting, so a Daily Mix whose top 3 artists share a Deezer picture (or whose fallback album arts all point at the same release) collapses to a single full-canvas tile instead of a contact-sheet of identical thumbnails. Mirrors the hash-level dedup that `playlist_cover::top_track_artwork_paths` applies for user playlists — both caches are hash-keyed (`metadata_artwork/<blake3>.jpg` + per-profile `artwork/<hash>.<ext>`), so path equality is content equality.
- 640×640 RGB canvas → centre-crop each source via `cover_fit` (matches CSS `object-fit: cover`) → SIMD resize via `fast_image_resize 6` → paint.
- Apply a `t²` ease-out gradient over the bottom 40 % so the React-rendered "Daily Mix N" label stays legible without baking text into the JPEG.
- Encode JPEG q=85 → blake3 → write to `metadata_artwork/<hash>.jpg`.
Expand Down
30 changes: 30 additions & 0 deletions src-tauri/src/smart_playlists/cover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,18 @@ const MAX_GRID_TILES: usize = 4;
/// disk write).
pub fn build_composite_cover(image_paths: &[PathBuf], metadata_dir: &Path) -> AppResult<String> {
let take = MAX_GRID_TILES.max(MAX_STRIPS);
// Dedupe by path so a Daily Mix whose top 3 artists share a picture
// (or whose fallback album arts all point at the same release)
// collapses to a single full-canvas tile instead of a contact-sheet
// of identical thumbnails. Mirrors the hash-level dedup that
// `playlist_cover::top_track_artwork_paths` already applies for
// user playlists — paths here are hash-keyed too (metadata_artwork
// cache + per-profile artwork dir), so path equality is hash equality.
let mut seen: std::collections::HashSet<&PathBuf> =
std::collections::HashSet::with_capacity(image_paths.len());
let tiles: Vec<RgbImage> = image_paths
.iter()
.filter(|p| seen.insert(*p))
.take(take)
.filter_map(|p| match image::open(p) {
Ok(img) => Some(img.to_rgb8()),
Expand Down Expand Up @@ -426,4 +436,24 @@ mod tests {
let bottom = canvas.get_pixel(0, CANVAS_PX - 1)[0];
assert!(bottom < 200, "expected darkening, got {bottom}");
}

#[test]
fn composite_collapses_identical_inputs_to_single_tile() {
// Three identical paths must produce the same cover as a single
// path — anything else means the Daily Mix carousel would still
// show a 3-strip contact sheet of the same picture.
let dir = tempfile::tempdir().expect("tempdir");
let src = dir.path().join("artist.jpg");
let img = solid(640, 640, [180, 120, 60]);
img.save_with_format(&src, ImageFormat::Jpeg)
.expect("write source jpg");

let hash_dup = build_composite_cover(&[src.clone(), src.clone(), src.clone()], dir.path())
.expect("dup composite");
let hash_single = build_composite_cover(&[src.clone()], dir.path()).expect("single composite");
assert_eq!(
hash_dup, hash_single,
"duplicate inputs should collapse to the single-tile composite"
);
}
}
Loading