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
- Missing Sync Checks: Audio has no public sync availability check
- Different Blocking Behaviors: Only beats can block navigation
- Inconsistent Error Handling: Different retry mechanisms across asset types
- No Criticality Support: Can't mark images/audio as navigation-blocking
- 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:
-
Add public sync methods to AudioManager:
func getAudioBuffer(_ name: String) -> AVAudioPCMBuffer?
func isAudioLoaded(_ name: String) -> Bool
func loadAudio(_ name: String) async throws -> AVAudioPCMBuffer -
Expose error handling and cancellation:
func cancelAudioLoading(_ name: String) async -
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:
-
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 -
Unify error handling with beats pattern
-
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:
-
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)
} -
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)
} -
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:
-
Asset criticality configuration:
enum AssetCriticality { case optional, important, critical }
private var imageCriticality: [String: AssetCriticality] = [:]
private var audioCriticality: [String: AssetCriticality] = [:] -
Navigation blocking logic:
func canNavigateToLocation(_ location: StoryLocation) async -> Bool {
// Check that all critical assets are loaded
} -
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:
-
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, ...)
} -
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:
-
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(...)
} -
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
- Phase 1-3: All existing code continues to work unchanged
- Phase 4-5: New features opt-in to criticality system
- 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
- Development: Implement behind feature flags
- Internal Testing: Enable for specific stories/features
- Beta: Gradual rollout with monitoring
- Production: Full rollout after validation
Testing Strategy
Unit Tests
- AudioManager: Test sync methods, async loading, error handling
- ImageLoadingService: Test unified interface consistency
- StoryContentManager: Test asset coordination and criticality
- LoadingStateManager: Test unified loading states
Integration Tests
- Navigation Blocking: Test critical asset blocking behavior
- Asset Coordination: Test mixed critical/optional loading
- Error Recovery: Test retry mechanisms across asset types
- Performance: Test loading times and memory usage
UI Tests
- Critical Image Loading: Test navigation blocking for images
- Critical Audio Loading: Test playback blocking for audio
- Progressive Loading: Test optional asset behavior unchanged
- Loading States: Test unified loading indicators
Performance Considerations
Memory Management
- Unified Caching: Coordinate cache limits across all asset types
- Priority Loading: Critical assets get priority in concurrent loading
- Smart Preloading: Avoid redundant loading across asset types
Network Optimization
- Batched Requests: Load multiple assets in parallel when possible
- Request Deduplication: Avoid duplicate requests across systems
- Intelligent Prioritization: Critical > Important > Optional
User Experience
- Loading Indicators: Unified loading states across asset types
- Progressive Enhancement: Optional assets enhance without blocking
- 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
- Smart Preloading: ML-based prediction of needed assets
- Adaptive Quality: Load different asset qualities based on network
- Offline Mode: Coordinated offline asset management
- 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:
- Critical Asset Support: Navigation blocking for essential content
- Immediate Availability Checks: Support for features like tap-triggered audio
- Consistent Error Handling: Unified retry and recovery mechanisms
- Future Extensibility: Easy addition of new asset types and features
- 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.