@@ -1,7 +1,10 @@ |
| 1 | 1 | //! Daemon state management |
| 2 | 2 | |
| 3 | 3 | use anyhow::{Context, Result}; |
| 4 | +use crossbeam_channel::Receiver; |
| 4 | 5 | use std::collections::HashMap; |
| 6 | +use std::sync::Arc; |
| 7 | +use std::thread::JoinHandle; |
| 5 | 8 | use std::time::Duration; |
| 6 | 9 | use tokio::net::UnixStream; |
| 7 | 10 | use tokio::signal::unix::{signal, SignalKind}; |
@@ -72,16 +75,36 @@ impl DaemonState { |
| 72 | 75 | } |
| 73 | 76 | } |
| 74 | 77 | |
| 78 | +/// Frame source strategy for animation playback |
| 79 | +enum FrameSource { |
| 80 | + /// All frames pre-scaled in memory (small animations within memory budget) |
| 81 | + Preloaded { |
| 82 | + scaled_frames: Vec<image::RgbaImage>, |
| 83 | + frame_delays: Vec<Duration>, |
| 84 | + current_frame: usize, |
| 85 | + }, |
| 86 | + /// Frames scaled on demand by a background producer thread (large animations) |
| 87 | + Streaming { |
| 88 | + /// Receiver for scaled frames from the producer |
| 89 | + rx: Receiver<(image::RgbaImage, Duration)>, |
| 90 | + /// Handle to the producer thread (dropped = detached, thread exits on send error) |
| 91 | + _producer: JoinHandle<()>, |
| 92 | + /// The last successfully received frame (re-displayed if producer is behind) |
| 93 | + last_frame: Option<image::RgbaImage>, |
| 94 | + /// Delay of the last frame |
| 95 | + last_delay: Duration, |
| 96 | + /// Total frame count (for logging) |
| 97 | + #[allow(dead_code)] |
| 98 | + frame_count: usize, |
| 99 | + }, |
| 100 | +} |
| 101 | + |
| 75 | 102 | /// Active animation state (works with GIF, WebP, or other animated formats) |
| 76 | 103 | pub struct ActiveAnimation { |
| 77 | | - /// Pre-scaled frames for quick rendering |
| 78 | | - scaled_frames: Vec<image::RgbaImage>, |
| 79 | | - /// Frame delays (parallel to scaled_frames) |
| 80 | | - frame_delays: Vec<Duration>, |
| 104 | + /// Frame source (preloaded or streaming) |
| 105 | + frame_source: FrameSource, |
| 81 | 106 | /// Animation renderer (double-buffered) |
| 82 | 107 | renderer: AnimationRenderer, |
| 83 | | - /// Current frame index |
| 84 | | - current_frame: usize, |
| 85 | 108 | /// Max FPS |
| 86 | 109 | max_fps: u32, |
| 87 | 110 | /// Scale mode (for status) |
@@ -93,7 +116,7 @@ pub struct ActiveAnimation { |
| 93 | 116 | } |
| 94 | 117 | |
| 95 | 118 | impl ActiveAnimation { |
| 96 | | - /// Create from animation frames (scales in parallel with fast filter) |
| 119 | + /// Create from animation frames, choosing pre-load or streaming based on memory budget |
| 97 | 120 | fn from_frames( |
| 98 | 121 | frames: &[AnimationFrame], |
| 99 | 122 | renderer: AnimationRenderer, |
@@ -102,14 +125,52 @@ impl ActiveAnimation { |
| 102 | 125 | source: String, |
| 103 | 126 | screen_width: u32, |
| 104 | 127 | screen_height: u32, |
| 128 | + memory_budget: u64, |
| 105 | 129 | ) -> Self { |
| 106 | | - // Scale frames in parallel, limited to available CPU cores |
| 130 | + let per_frame_bytes = screen_width as u64 * screen_height as u64 * 4; |
| 131 | + let total_bytes = per_frame_bytes * frames.len() as u64; |
| 132 | + |
| 133 | + let frame_source = if total_bytes <= memory_budget { |
| 134 | + tracing::info!( |
| 135 | + "Animation fits in memory budget ({:.1} MB <= {:.1} MB), pre-scaling all {} frames", |
| 136 | + total_bytes as f64 / (1024.0 * 1024.0), |
| 137 | + memory_budget as f64 / (1024.0 * 1024.0), |
| 138 | + frames.len(), |
| 139 | + ); |
| 140 | + Self::preload_frames(frames, screen_width, screen_height, scale_mode) |
| 141 | + } else { |
| 142 | + let max_buffered = (memory_budget / 2 / per_frame_bytes).max(2).min(8) as usize; |
| 143 | + tracing::info!( |
| 144 | + "Animation exceeds memory budget ({:.1} MB > {:.1} MB), streaming with {} frame buffer", |
| 145 | + total_bytes as f64 / (1024.0 * 1024.0), |
| 146 | + memory_budget as f64 / (1024.0 * 1024.0), |
| 147 | + max_buffered, |
| 148 | + ); |
| 149 | + Self::stream_frames(frames, screen_width, screen_height, scale_mode, max_buffered) |
| 150 | + }; |
| 151 | + |
| 152 | + Self { |
| 153 | + frame_source, |
| 154 | + renderer, |
| 155 | + max_fps, |
| 156 | + scale_mode, |
| 157 | + source, |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + /// Pre-scale all frames in parallel (existing behavior, for small animations) |
| 162 | + fn preload_frames( |
| 163 | + frames: &[AnimationFrame], |
| 164 | + screen_width: u32, |
| 165 | + screen_height: u32, |
| 166 | + scale_mode: ScaleMode, |
| 167 | + ) -> FrameSource { |
| 107 | 168 | let num_cpus = std::thread::available_parallelism() |
| 108 | 169 | .map(|n| n.get()) |
| 109 | 170 | .unwrap_or(4); |
| 171 | + |
| 110 | 172 | let scaled_frames: Vec<image::RgbaImage> = std::thread::scope(|s| { |
| 111 | 173 | let mut results = Vec::with_capacity(frames.len()); |
| 112 | | - // Process in batches of num_cpus to avoid spawning too many threads |
| 113 | 174 | for chunk in frames.chunks(num_cpus) { |
| 114 | 175 | let handles: Vec<_> = chunk |
| 115 | 176 | .iter() |
@@ -124,25 +185,68 @@ impl ActiveAnimation { |
| 124 | 185 | results |
| 125 | 186 | }); |
| 126 | 187 | |
| 127 | | - let frame_delays: Vec<Duration> = frames |
| 128 | | - .iter() |
| 129 | | - .map(|frame| frame.delay) |
| 130 | | - .collect(); |
| 188 | + let frame_delays: Vec<Duration> = frames.iter().map(|f| f.delay).collect(); |
| 131 | 189 | |
| 132 | | - Self { |
| 190 | + FrameSource::Preloaded { |
| 133 | 191 | scaled_frames, |
| 134 | 192 | frame_delays, |
| 135 | | - renderer, |
| 136 | 193 | current_frame: 0, |
| 137 | | - max_fps, |
| 138 | | - scale_mode, |
| 139 | | - source, |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + /// Set up streaming producer thread for large animations |
| 198 | + fn stream_frames( |
| 199 | + frames: &[AnimationFrame], |
| 200 | + screen_width: u32, |
| 201 | + screen_height: u32, |
| 202 | + scale_mode: ScaleMode, |
| 203 | + buffer_capacity: usize, |
| 204 | + ) -> FrameSource { |
| 205 | + let (tx, rx) = crossbeam_channel::bounded::<(image::RgbaImage, Duration)>(buffer_capacity); |
| 206 | + let source_frames: Arc<Vec<AnimationFrame>> = Arc::new(frames.to_vec()); |
| 207 | + let frame_count = frames.len(); |
| 208 | + |
| 209 | + let producer = std::thread::Builder::new() |
| 210 | + .name("garbg-frame-scaler".into()) |
| 211 | + .spawn(move || { |
| 212 | + let mut index = 0usize; |
| 213 | + loop { |
| 214 | + let frame = &source_frames[index % frame_count]; |
| 215 | + let scaled = scale_image_fast( |
| 216 | + &frame.image, |
| 217 | + screen_width, |
| 218 | + screen_height, |
| 219 | + scale_mode, |
| 220 | + ); |
| 221 | + if tx.send((scaled, frame.delay)).is_err() { |
| 222 | + tracing::debug!("Frame producer exiting: receiver dropped"); |
| 223 | + break; |
| 224 | + } |
| 225 | + index += 1; |
| 226 | + } |
| 227 | + }) |
| 228 | + .expect("failed to spawn frame scaler thread"); |
| 229 | + |
| 230 | + // Block on first frame so animation starts with content |
| 231 | + let (first_frame, first_delay) = rx.recv().expect("producer died before first frame"); |
| 232 | + |
| 233 | + FrameSource::Streaming { |
| 234 | + rx, |
| 235 | + _producer: producer, |
| 236 | + last_frame: Some(first_frame), |
| 237 | + last_delay: first_delay, |
| 238 | + frame_count, |
| 140 | 239 | } |
| 141 | 240 | } |
| 142 | 241 | |
| 143 | 242 | /// Get the delay for the current frame |
| 144 | 243 | fn current_delay(&self) -> Duration { |
| 145 | | - let frame_delay = self.frame_delays[self.current_frame]; |
| 244 | + let frame_delay = match &self.frame_source { |
| 245 | + FrameSource::Preloaded { frame_delays, current_frame, .. } => { |
| 246 | + frame_delays[*current_frame] |
| 247 | + } |
| 248 | + FrameSource::Streaming { last_delay, .. } => *last_delay, |
| 249 | + }; |
| 146 | 250 | let min_delay = if self.max_fps > 0 { |
| 147 | 251 | Duration::from_secs_f64(1.0 / self.max_fps as f64) |
| 148 | 252 | } else { |
@@ -153,19 +257,41 @@ impl ActiveAnimation { |
| 153 | 257 | |
| 154 | 258 | /// Advance to next frame, returning true if looped |
| 155 | 259 | fn advance(&mut self) -> bool { |
| 156 | | - self.current_frame += 1; |
| 157 | | - if self.current_frame >= self.scaled_frames.len() { |
| 158 | | - self.current_frame = 0; |
| 159 | | - true |
| 160 | | - } else { |
| 161 | | - false |
| 260 | + match &mut self.frame_source { |
| 261 | + FrameSource::Preloaded { scaled_frames, current_frame, .. } => { |
| 262 | + *current_frame += 1; |
| 263 | + if *current_frame >= scaled_frames.len() { |
| 264 | + *current_frame = 0; |
| 265 | + true |
| 266 | + } else { |
| 267 | + false |
| 268 | + } |
| 269 | + } |
| 270 | + FrameSource::Streaming { rx, last_frame, last_delay, .. } => { |
| 271 | + match rx.try_recv() { |
| 272 | + Ok((frame, delay)) => { |
| 273 | + *last_frame = Some(frame); |
| 274 | + *last_delay = delay; |
| 275 | + } |
| 276 | + Err(crossbeam_channel::TryRecvError::Empty) => { |
| 277 | + tracing::trace!("Frame producer behind, re-displaying last frame"); |
| 278 | + } |
| 279 | + Err(crossbeam_channel::TryRecvError::Disconnected) => { |
| 280 | + tracing::warn!("Frame producer disconnected"); |
| 281 | + } |
| 282 | + } |
| 283 | + false |
| 284 | + } |
| 162 | 285 | } |
| 163 | 286 | } |
| 164 | 287 | |
| 165 | 288 | /// Get frame count |
| 166 | 289 | #[allow(dead_code)] |
| 167 | 290 | fn frame_count(&self) -> usize { |
| 168 | | - self.scaled_frames.len() |
| 291 | + match &self.frame_source { |
| 292 | + FrameSource::Preloaded { scaled_frames, .. } => scaled_frames.len(), |
| 293 | + FrameSource::Streaming { frame_count, .. } => *frame_count, |
| 294 | + } |
| 169 | 295 | } |
| 170 | 296 | } |
| 171 | 297 | |
@@ -609,9 +735,17 @@ impl Daemon { |
| 609 | 735 | let anim = self.animation.as_mut() |
| 610 | 736 | .ok_or_else(|| anyhow::anyhow!("No active animation"))?; |
| 611 | 737 | |
| 612 | | - // Render current frame |
| 613 | | - let frame = &anim.scaled_frames[anim.current_frame]; |
| 614 | | - anim.renderer.render_and_present(conn, frame)?; |
| 738 | + // Render current frame (inline match for split borrowing of frame_source vs renderer) |
| 739 | + match &anim.frame_source { |
| 740 | + FrameSource::Preloaded { scaled_frames, current_frame, .. } => { |
| 741 | + anim.renderer.render_and_present(conn, &scaled_frames[*current_frame])?; |
| 742 | + } |
| 743 | + FrameSource::Streaming { last_frame, .. } => { |
| 744 | + if let Some(frame) = last_frame { |
| 745 | + anim.renderer.render_and_present(conn, frame)?; |
| 746 | + } |
| 747 | + } |
| 748 | + } |
| 615 | 749 | |
| 616 | 750 | // Advance to next frame |
| 617 | 751 | anim.advance(); |
@@ -995,13 +1129,19 @@ impl Daemon { |
| 995 | 1129 | let renderer = AnimationRenderer::new(conn)?; |
| 996 | 1130 | let (width, height) = conn.screen_dimensions(); |
| 997 | 1131 | |
| 1132 | + let per_frame_mb = (width as f64 * height as f64 * 4.0) / (1024.0 * 1024.0); |
| 998 | 1133 | tracing::info!( |
| 999 | | - "Animation loaded: {} frames, {:.1} FPS ({})", |
| 1134 | + "Animation loaded: {} frames, {:.1} FPS ({}), screen {}x{}, {:.1} MB/frame, {:.1} MB total", |
| 1000 | 1135 | frame_count, |
| 1001 | 1136 | avg_fps, |
| 1002 | | - format_name |
| 1137 | + format_name, |
| 1138 | + width, |
| 1139 | + height, |
| 1140 | + per_frame_mb, |
| 1141 | + per_frame_mb * frame_count as f64, |
| 1003 | 1142 | ); |
| 1004 | 1143 | |
| 1144 | + let memory_budget = self.state.config.animation.memory_budget_mb * 1024 * 1024; |
| 1005 | 1145 | self.animation = Some(ActiveAnimation::from_frames( |
| 1006 | 1146 | &frames, |
| 1007 | 1147 | renderer, |
@@ -1010,6 +1150,7 @@ impl Daemon { |
| 1010 | 1150 | source.to_string(), |
| 1011 | 1151 | width as u32, |
| 1012 | 1152 | height as u32, |
| 1153 | + memory_budget, |
| 1013 | 1154 | )); |
| 1014 | 1155 | |
| 1015 | 1156 | Ok(()) |
@@ -1067,12 +1208,18 @@ impl Daemon { |
| 1067 | 1208 | let renderer = AnimationRenderer::new(conn)?; |
| 1068 | 1209 | let (width, height) = conn.screen_dimensions(); |
| 1069 | 1210 | |
| 1211 | + let per_frame_mb = (width as f64 * height as f64 * 4.0) / (1024.0 * 1024.0); |
| 1070 | 1212 | tracing::info!( |
| 1071 | | - "Video loaded: {} frames, {:.1} FPS", |
| 1213 | + "Video loaded: {} frames, {:.1} FPS, screen {}x{}, {:.1} MB/frame, {:.1} MB total", |
| 1072 | 1214 | frame_count, |
| 1073 | | - avg_fps |
| 1215 | + avg_fps, |
| 1216 | + width, |
| 1217 | + height, |
| 1218 | + per_frame_mb, |
| 1219 | + per_frame_mb * frame_count as f64, |
| 1074 | 1220 | ); |
| 1075 | 1221 | |
| 1222 | + let memory_budget = self.state.config.animation.memory_budget_mb * 1024 * 1024; |
| 1076 | 1223 | self.animation = Some(ActiveAnimation::from_frames( |
| 1077 | 1224 | &frames, |
| 1078 | 1225 | renderer, |
@@ -1081,6 +1228,7 @@ impl Daemon { |
| 1081 | 1228 | source.to_string(), |
| 1082 | 1229 | width as u32, |
| 1083 | 1230 | height as u32, |
| 1231 | + memory_budget, |
| 1084 | 1232 | )); |
| 1085 | 1233 | |
| 1086 | 1234 | Ok(()) |