Skip to main content

Unified Asset Loading Architecture Plan

Executive Summary

This document outlines a plan to unify the asset loading patterns across beats, images, and audio in the Pajama story reader. The goal is to implement a consistent "ask for it immediately, if not available then wait for it" pattern that allows for critical asset blocking, predictable loading states, and future-proof extensibility.

Current Architecture Analysis

Existing Patterns

Beat Loading (Current - Works Well)

// StoryContentManager pattern
func getBeat(_ beatId: BeatId) -> PajamaScriptStoryBeat? // Sync cache check
func loadBeat(_ beatId: BeatId) async throws -> PajamaScriptStoryBeat // Async load with hierarchy
func isBeatLoaded(_ beatId: BeatId) -> Bool // Sync availability check
func doesBeatExist(_ beatId: BeatId) -> Bool // Sync existence check

// Usage pattern
if let beat = contentManager.getBeat(beatId) {
// Use immediately
} else {
// Show loading, async load with retry
beat = try await contentManager.loadBeat(beatId)
}

Image Loading (Current - Inconsistent)

// ImageLoadingService pattern
func loadImage(for url: URL, processing: ProcessingConfiguration) async -> CGImage?
func preloadImages(for urlStrings: [String], processing: ProcessingConfiguration)
func getMemoryCachedImageSync(for urlString: String, processing: ProcessingConfiguration) -> CGImage?

// Usage pattern - UI components only
CachedAsyncImage(url: url, imageLoader: service) { image in
// Display when loaded
} placeholder: {
// Show loading spinner
}

Audio Loading (Current - Fire-and-Forget)

// AudioManager pattern
func preloadSounds(names: [String], soundType: SoundType) // Fire-and-forget
func playSoundEffect(named filename: String) // Fails silently if not loaded
private func fetchBuffer(name: String, _ type: SoundType) async -> AVAudioPCMBuffer? // Private only

// Usage pattern
AudioManager.shared.preloadSounds(names: audioUrls, soundType: .effect) // Hope it's ready later
AudioManager.shared.playSoundEffect(named: url) // No way to check if ready

Identified Inconsistencies

  1. Missing Sync Checks: Audio has no public sync availability check
  2. Different Blocking Behaviors: Only beats can block navigation
  3. Inconsistent Error Handling: Different retry mechanisms across asset types
  4. No Criticality Support: Can't mark images/audio as navigation-blocking
  5. Limited Future Extensibility: Hard to add new asset-dependent features

Vision: Unified Asset Loading Pattern

Core Principle

All assets should support the same "sync check, async load" pattern that beats use successfully.

Unified Interface Design

protocol AssetManaging {
associatedtype Asset
associatedtype AssetId

// Sync operations (immediate cache check)
func getAsset(_ id: AssetId) -> Asset?
func isAssetLoaded(_ id: AssetId) -> Bool
func doesAssetExist(_ id: AssetId) -> Bool

// Async operations (full hierarchy loading)
func loadAsset(_ id: AssetId) async throws -> Asset
func preloadAssets(_ ids: [AssetId]) -> Void

// Management
func cancelAssetLoading(_ id: AssetId) async
func clearCache() async
}

Enhanced StoryContentManager

class StoryContentManager {
// MARK: - Unified Asset Access Pattern

// Beats (existing - works well)
func getBeat(_ beatId: BeatId) -> PajamaScriptStoryBeat?
func loadBeat(_ beatId: BeatId) async throws -> PajamaScriptStoryBeat

// Images (new unified pattern)
func getImage(_ url: String, processing: ProcessingConfiguration = .storyImage) -> CGImage?
func loadImage(_ url: String, processing: ProcessingConfiguration = .storyImage) async throws -> CGImage
func isImageLoaded(_ url: String, processing: ProcessingConfiguration = .storyImage) -> Bool

// Audio (new unified pattern)
func getAudio(_ url: String) -> AVAudioPCMBuffer?
func loadAudio(_ url: String) async throws -> AVAudioPCMBuffer
func isAudioLoaded(_ url: String) -> Bool

// MARK: - Asset Criticality System

enum AssetCriticality {
case optional // Progressive loading OK (current image behavior)
case important // Preload aggressively but don't block navigation
case critical // Block navigation until loaded (current beat behavior)
}

func setImageCriticality(_ criticality: AssetCriticality, for url: String)
func setAudioCriticality(_ criticality: AssetCriticality, for url: String)

// MARK: - Navigation-Aware Loading

func canNavigateToLocation(_ location: StoryLocation) async -> Bool {
guard let beat = getBeat(location.beatId) else { return false }

// Check critical assets for this location
let criticalAssets = getCriticalAssets(for: location)
return await areAssetsReady(criticalAssets)
}

func loadAssetsForLocation(_ location: StoryLocation,
priority: AssetPriority = .normal) async throws {
// Load all critical assets before allowing navigation
let criticalAssets = getCriticalAssets(for: location)
try await loadAssets(criticalAssets, priority: .critical)

// Start preloading important/optional assets
let optionalAssets = getOptionalAssets(for: location)
preloadAssets(optionalAssets, priority: priority)
}
}

Implementation Strategy

Phase 1: Foundation - Audio Manager Enhancement

Goal: Bring AudioManager up to beat loading pattern standards

Changes:

  1. Add public sync methods to AudioManager:

    func getAudioBuffer(_ name: String) -> AVAudioPCMBuffer?
    func isAudioLoaded(_ name: String) -> Bool
    func loadAudio(_ name: String) async throws -> AVAudioPCMBuffer
  2. Expose error handling and cancellation:

    func cancelAudioLoading(_ name: String) async
  3. Add asset existence checking:

    func doesAudioExist(_ name: String) -> Bool  // Check if URL is valid

Testing: Ensure audio loading works with sync checks before playback

Phase 2: Foundation - Image Service Enhancement

Goal: Add missing sync methods to ImageLoadingService

Changes:

  1. Enhance ImageLoadingServiceProtocol:

    func getImage(_ url: String, processing: ProcessingConfiguration) -> CGImage?
    func isImageLoaded(_ url: String, processing: ProcessingConfiguration) -> Bool
    func loadImage(_ url: String, processing: ProcessingConfiguration) async throws -> CGImage
  2. Unify error handling with beats pattern

  3. Add cancellation support per image URL

Testing: Verify image loading works with explicit async calls

Phase 3: StoryContentManager Unification

Goal: Add unified asset methods to StoryContentManager

Changes:

  1. Add image methods that delegate to ImageLoadingService:

    func getImage(_ url: String, processing: ProcessingConfiguration = .storyImage) -> CGImage? {
    imageLoadingService.getImage(url, processing: processing)
    }

    func loadImage(_ url: String, processing: ProcessingConfiguration = .storyImage) async throws -> CGImage {
    try await imageLoadingService.loadImage(url, processing: processing)
    }
  2. Add audio methods that delegate to AudioManager:

    func getAudio(_ url: String) -> AVAudioPCMBuffer? {
    AudioManager.shared.getAudioBuffer(url)
    }

    func loadAudio(_ url: String) async throws -> AVAudioPCMBuffer {
    try await AudioManager.shared.loadAudio(url)
    }
  3. Implement asset extraction and management:

    private func extractCriticalAssets(for location: StoryLocation) -> [AssetReference] {
    // Extract images, audio from beat pages based on criticality configuration
    }

Testing: Verify unified interface works end-to-end

Phase 4: Asset Criticality System

Goal: Allow configuration of which assets block navigation

Changes:

  1. Asset criticality configuration:

    enum AssetCriticality { case optional, important, critical }
    private var imageCriticality: [String: AssetCriticality] = [:]
    private var audioCriticality: [String: AssetCriticality] = [:]
  2. Navigation blocking logic:

    func canNavigateToLocation(_ location: StoryLocation) async -> Bool {
    // Check that all critical assets are loaded
    }
  3. Integration with LoadingStateManager for unified loading UI

Testing: Test navigation blocking for critical assets

Phase 5: Navigation Integration

Goal: Integrate asset loading with navigation system

Changes:

  1. Update StoryReaderViewModel navigation methods:

    func changePage(to location: StoryLocation, direction: NavigationDirection, reason: NavigationReason) async {
    // Check if critical assets are ready
    if await !contentManager.canNavigateToLocation(location) {
    // Show loading state and load critical assets
    try await contentManager.loadAssetsForLocation(location, priority: .critical)
    }

    // Proceed with navigation
    await pageController?.executeNavigation(to: location, direction: direction, ...)
    }
  2. Update choice handling to check audio availability:

    func makeChoice(choiceIndex: Int) async {
    // For tap-triggered audio, ensure it's loaded
    if let tapAudio = getTapAudioForChoice(choiceIndex) {
    if !contentManager.isAudioLoaded(tapAudio) {
    try await contentManager.loadAudio(tapAudio)
    }
    }
    // Proceed with choice...
    }

Testing: End-to-end navigation with asset loading

Phase 6: UI Component Migration

Goal: Migrate UI components to use unified pattern where beneficial

Changes:

  1. Update page views to support critical image loading:

    // For critical images, block until loaded
    if isImageCritical {
    if let image = viewModel.contentManager.getImage(imageUrl) {
    // Display immediately
    } else {
    // Show loading and wait
    let image = try await viewModel.contentManager.loadImage(imageUrl)
    }
    } else {
    // Use existing CachedAsyncImage for progressive loading
    CachedAsyncImage(...)
    }
  2. Add immediate audio checks for tap triggers:

    .onTapGesture {
    if let tapAudio = page.tapAudio {
    if viewModel.contentManager.isAudioLoaded(tapAudio) {
    AudioManager.shared.playSoundEffect(named: tapAudio)
    } else {
    // Either skip audio or show loading
    }
    }
    }

Testing: Verify UI behavior for both critical and optional assets

API Design Details

Error Handling

All asset loading should use consistent error types:

enum AssetLoadingError: Error {
case notFound(assetId: String)
case networkError(underlying: Error)
case processingError(underlying: Error)
case cancelled
case timeout
case invalidFormat
}

Loading States

Unified loading state management:

extension LoadingStateManager {
func executeAssetLoading<T>(
description: String,
assetId: String,
operation: () async throws -> T
) async rethrows -> T {
// Same retry/timeout logic as beat loading
}
}

Configuration

Asset behavior configuration:

struct AssetConfiguration {
var imageCriticality: [String: AssetCriticality] = [:]
var audioCriticality: [String: AssetCriticality] = [:]
var enableImageBlocking: Bool = false
var enableAudioBlocking: Bool = false
var defaultImageCriticality: AssetCriticality = .optional
var defaultAudioCriticality: AssetCriticality = .optional
}

Migration Strategy

Backward Compatibility

  1. Phase 1-3: All existing code continues to work unchanged
  2. Phase 4-5: New features opt-in to criticality system
  3. Phase 6: UI components gradually migrate to unified pattern

Feature Flags

struct FeatureFlags {
static var enableUnifiedAssetLoading = false
static var enableAssetCriticality = false
static var enableImageBlocking = false
static var enableAudioBlocking = false
}

Rollout Plan

  1. Development: Implement behind feature flags
  2. Internal Testing: Enable for specific stories/features
  3. Beta: Gradual rollout with monitoring
  4. Production: Full rollout after validation

Testing Strategy

Unit Tests

  1. AudioManager: Test sync methods, async loading, error handling
  2. ImageLoadingService: Test unified interface consistency
  3. StoryContentManager: Test asset coordination and criticality
  4. LoadingStateManager: Test unified loading states

Integration Tests

  1. Navigation Blocking: Test critical asset blocking behavior
  2. Asset Coordination: Test mixed critical/optional loading
  3. Error Recovery: Test retry mechanisms across asset types
  4. Performance: Test loading times and memory usage

UI Tests

  1. Critical Image Loading: Test navigation blocking for images
  2. Critical Audio Loading: Test playback blocking for audio
  3. Progressive Loading: Test optional asset behavior unchanged
  4. Loading States: Test unified loading indicators

Performance Considerations

Memory Management

  1. Unified Caching: Coordinate cache limits across all asset types
  2. Priority Loading: Critical assets get priority in concurrent loading
  3. Smart Preloading: Avoid redundant loading across asset types

Network Optimization

  1. Batched Requests: Load multiple assets in parallel when possible
  2. Request Deduplication: Avoid duplicate requests across systems
  3. Intelligent Prioritization: Critical > Important > Optional

User Experience

  1. Loading Indicators: Unified loading states across asset types
  2. Progressive Enhancement: Optional assets enhance without blocking
  3. Error Recovery: Consistent retry behavior for all assets

Success Metrics

Functionality

  • Audio loading supports sync availability checks
  • Image loading supports async explicit loading
  • Navigation can block for critical assets
  • Unified error handling across all asset types
  • Feature flags allow gradual rollout

Performance

  • Loading times comparable to current architecture
  • Memory usage within acceptable bounds
  • Network requests remain efficient
  • Cache hit rates maintain or improve

User Experience

  • Smooth navigation for critical assets
  • Progressive loading preserved for optional assets
  • Consistent loading indicators
  • Reliable error recovery

Future Extensions

New Asset Types

The unified pattern makes it easy to add new asset types:

// Video assets
func getVideo(_ url: String) -> AVAsset?
func loadVideo(_ url: String) async throws -> AVAsset

// 3D models
func getModel(_ url: String) -> SCNNode?
func loadModel(_ url: String) async throws -> SCNNode

Advanced Features

  1. Smart Preloading: ML-based prediction of needed assets
  2. Adaptive Quality: Load different asset qualities based on network
  3. Offline Mode: Coordinated offline asset management
  4. Analytics: Unified asset loading metrics

Conclusion

This unified asset loading architecture brings consistency, predictability, and extensibility to the Pajama asset system. By adopting the successful beat loading pattern for images and audio, we enable:

  1. Critical Asset Support: Navigation blocking for essential content
  2. Immediate Availability Checks: Support for features like tap-triggered audio
  3. Consistent Error Handling: Unified retry and recovery mechanisms
  4. Future Extensibility: Easy addition of new asset types and features
  5. Backward Compatibility: Gradual migration without breaking changes

The phased implementation approach minimizes risk while delivering incremental value, and the feature flag system allows for careful rollout and validation.