> ## Documentation Index
> Fetch the complete documentation index at: https://docs.r2vault.app/llms.txt
> Use this file to discover all available pages before exploring further.

# ThumbnailCache

> Thread-safe thumbnail cache with memory and disk persistence

## Overview

`ThumbnailCache` provides a two-tier caching system for R2 object thumbnails. It combines in-memory caching with disk persistence and implements request coalescing to prevent duplicate fetches. The actor-based design ensures thread-safe access from any context.

<Info>
  Located at: `Fiaxe/Services/ThumbnailCache.swift:6`
</Info>

## Type Definition

```swift theme={null}
actor ThumbnailCache
```

Implemented as an actor for thread-safe concurrent access. All methods can be called from any isolation context.

### Singleton Instance

```swift theme={null}
static let shared = ThumbnailCache()
```

Access the shared cache instance.

## Methods

### thumbnail()

Retrieves or generates a thumbnail for an R2 object key.

```swift theme={null}
func thumbnail(for key: String, credentials: R2Credentials) async -> NSImage?
```

<ParamField path="key" type="String" required>
  The R2 object key to generate a thumbnail for (e.g., `"photos/image.jpg"`).
</ParamField>

<ParamField path="credentials" type="R2Credentials" required>
  R2 credentials used to generate presigned URLs for fetching the image.
</ParamField>

**Returns:** Thumbnail image (120×120 max), or `nil` if generation fails.

#### Implementation Details

```swift theme={null}
// Example from ThumbnailCache.swift:30-64
func thumbnail(for key: String, credentials: R2Credentials) async -> NSImage? {
    // Scope cache key by bucket so different buckets never share thumbnails
    let scopedKey = "\(credentials.bucketName)/\(key)"
    let cacheKey = scopedKey as NSString

    // 1. Memory cache hit
    if let img = memoryCache.object(forKey: cacheKey) { return img }

    // 2. Disk cache hit
    if let img = loadFromDisk(key: scopedKey) {
        memoryCache.setObject(img, forKey: cacheKey)
        return img
    }

    // 3. Coalesce in-flight requests
    if let task = inFlight[scopedKey] { return await task.value }

    // Generate presigned URL — works whether or not a custom domain is set
    guard let url = AWSV4Signer.presignedURL(for: key, credentials: credentials) else {
        return nil
    }

    let task = Task<NSImage?, Never> {
        let img = await fetchThumbnail(url: url, key: key)
        if let img {
            memoryCache.setObject(img, forKey: cacheKey)
            saveToDisk(img, key: scopedKey)
        }
        return img
    }
    inFlight[scopedKey] = task
    let result = await task.value
    inFlight.removeValue(forKey: scopedKey)
    return result
}
```

The method implements a three-tier lookup:

1. **Memory cache** - Instant return if cached in `NSCache`
2. **Disk cache** - Load from disk if available, promote to memory
3. **Network fetch** - Generate thumbnail from R2, cache in both memory and disk

<Tip>
  **Request Coalescing**

  If multiple requests for the same thumbnail arrive simultaneously, only one network request is made. Other requests await the same task result.
</Tip>

### clearMemory()

Clears the entire in-memory cache.

```swift theme={null}
func clearMemory()
```

Useful for freeing memory during low-memory conditions. Disk cache is preserved.

## Cache Configuration

The cache is configured with size limits:

```swift theme={null}
private init() {
    memoryCache.countLimit = 500           // Max 500 images
    memoryCache.totalCostLimit = 100 * 1024 * 1024  // Max 100 MB
}
```

<ParamField path="countLimit" type="Int">
  Maximum number of images in memory cache (500).
</ParamField>

<ParamField path="totalCostLimit" type="Int">
  Maximum memory footprint in bytes (100 MB).
</ParamField>

`NSCache` automatically evicts least-recently-used items when limits are exceeded.

## Disk Cache

Thumbnails are persisted to the user's cache directory:

```swift theme={null}
private let diskCacheURL: URL = {
    let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
    let dir = caches.appendingPathComponent("R2VaultThumbnails", isDirectory: true)
    try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
    return dir
}()
```

Disk cache location:

```text theme={null}
~/Library/Caches/R2VaultThumbnails/
```

### File Naming

Cache keys are sanitized for safe filenames:

```swift theme={null}
private func diskURL(for key: String) -> URL {
    // Use a safe filename derived from the key
    let safe = key
        .replacingOccurrences(of: "/", with: "_")
        .replacingOccurrences(of: ":", with: "_")
    return diskCacheURL.appendingPathComponent(safe + ".png")
}
```

Examples:

* `photos/vacation.jpg` → `photos_vacation.jpg.png`
* `bucket:folder/file.png` → `bucket_folder_file.png.png`

### Storage Format

All thumbnails are stored as PNG:

```swift theme={null}
private func saveToDisk(_ image: NSImage, key: String) {
    guard let tiff = image.tiffRepresentation,
          let rep = NSBitmapImageRep(data: tiff),
          let png = rep.representation(using: .png, properties: [:])
    else { return }
    try? png.write(to: diskURL(for: key))
}
```

PNG format provides lossless compression suitable for thumbnails.

## Thumbnail Generation

### Image Thumbnails

Generated by downloading and resizing:

```swift theme={null}
private func imageThumbnail(url: URL) async -> NSImage? {
    guard let (data, _) = try? await URLSession.shared.data(from: url),
          let src = NSImage(data: data)
    else { return nil }
    return resized(src, to: CGSize(width: 120, height: 120))
}
```

### Video Thumbnails

Generated using AVFoundation:

```swift theme={null}
private func videoThumbnail(url: URL) async -> NSImage? {
    let asset = AVURLAsset(url: url)
    let gen = AVAssetImageGenerator(asset: asset)
    gen.appliesPreferredTrackTransform = true
    gen.maximumSize = CGSize(width: 120, height: 120)
    guard let cgImage = try? await gen.image(at: .zero).image
    else { return nil }
    return NSImage(cgImage: cgImage, size: NSSize(width: 120, height: 120))
}
```

Extracts the first frame from video files.

<CardGroup cols={2}>
  <Card title="Supported Images" icon="image">
    JPEG, PNG, GIF, HEIC, BMP, TIFF, WebP
  </Card>

  <Card title="Supported Videos" icon="film">
    MP4, MOV, AVI, MKV, WebM, M4V
  </Card>
</CardGroup>

## Resize Algorithm

The cache maintains aspect ratio while fitting within 120×120:

```swift theme={null}
private func resized(_ image: NSImage, to size: CGSize) -> NSImage {
    let original = image.size
    guard original.width > 0, original.height > 0 else { return image }

    let scale = min(size.width / original.width, size.height / original.height)
    let newSize = CGSize(width: original.width * scale, height: original.height * scale)

    let result = NSImage(size: newSize)
    result.lockFocus()
    image.draw(in: CGRect(origin: .zero, size: newSize),
               from: CGRect(origin: .zero, size: original),
               operation: .copy, fraction: 1)
    result.unlockFocus()
    return result
}
```

Examples:

* 1920×1080 → 120×67.5 (fits width)
* 800×1200 → 80×120 (fits height)
* 100×100 → 100×100 (no upscaling)

## Request Coalescing

Prevents duplicate fetches for the same thumbnail:

```swift theme={null}
private var inFlight: [String: Task<NSImage?, Never>] = [:]

// 3. Coalesce in-flight requests
if let task = inFlight[scopedKey] { return await task.value }

let task = Task<NSImage?, Never> {
    let img = await fetchThumbnail(url: url, key: key)
    // ... cache and return
}
inFlight[scopedKey] = task
let result = await task.value
inFlight.removeValue(forKey: scopedKey)
```

If 10 views request the same thumbnail simultaneously:

* Only 1 network request is made
* All 10 requests await the same task
* All receive the same result

## Bucket Scoping

Cache keys are scoped by bucket name:

```swift theme={null}
let scopedKey = "\(credentials.bucketName)/\(key)"
```

This ensures:

* Different buckets with same key names don't share thumbnails
* Switching between buckets doesn't show wrong thumbnails
* Cache hits are always bucket-specific

## Usage Example

```swift theme={null}
import SwiftUI

struct ThumbnailView: View {
    let object: R2Object
    let credentials: R2Credentials
    @State private var thumbnail: NSImage?
    
    var body: some View {
        Group {
            if let thumbnail {
                Image(nsImage: thumbnail)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 120, height: 120)
            } else {
                ProgressView()
                    .frame(width: 120, height: 120)
            }
        }
        .task {
            // Automatically fetches thumbnail
            thumbnail = await ThumbnailCache.shared.thumbnail(
                for: object.key,
                credentials: credentials
            )
        }
    }
}
```

## Performance Characteristics

<AccordionGroup>
  <Accordion title="Memory Efficiency">
    `NSCache` automatically evicts items under memory pressure. The 100 MB limit prevents excessive memory usage.
  </Accordion>

  <Accordion title="Disk Efficiency">
    PNG compression reduces disk space. A 120×120 thumbnail typically uses 10-50 KB.
  </Accordion>

  <Accordion title="Concurrency">
    Actor isolation ensures safe concurrent access. Multiple views can request thumbnails simultaneously without race conditions.
  </Accordion>

  <Accordion title="Network Efficiency">
    Request coalescing prevents duplicate downloads. Once cached, thumbnails load instantly.
  </Accordion>
</AccordionGroup>

## Cache Persistence

<Note>
  **Disk Cache Lifetime**

  The disk cache persists across app launches and remains until:

  * User manually clears cache (if implemented)
  * macOS automatically clears \~/Library/Caches during cleanup
  * App is uninstalled

  Memory cache is cleared on every app launch.
</Note>

## Error Handling

The cache gracefully handles errors:

```swift theme={null}
func thumbnail(for key: String, credentials: R2Credentials) async -> NSImage? {
    // Returns nil on failure
}
```

Failure cases:

* Invalid presigned URL
* Network errors
* Unsupported file formats
* Corrupted images
* Disk write failures

All failures return `nil` rather than throwing errors, allowing UI to show fallback icons.

## Thread Safety

<Tip>
  **Actor Isolation**

  The `actor` keyword ensures all cache operations are serialized:

  * No race conditions on `inFlight` dictionary
  * Safe `NSCache` access (though `NSCache` is already thread-safe)
  * Predictable behavior under concurrent load

  ```swift theme={null}
  // Safe from any context
  Task.detached {
      let thumb = await ThumbnailCache.shared.thumbnail(for: key, credentials: creds)
  }
  ```
</Tip>

## Related Services

* [AWSV4Signer](/dev/services/aws-v4-signer) - Generates presigned URLs for fetching
* [QuickLookCoordinator](/dev/services/quick-look-coordinator) - Full-size previews
* [R2BrowseService](/dev/services/r2-browse-service) - Provides R2Object data
