@@ -1,7 +1,10 @@ |
| 1 | //! Daemon state management | 1 | //! Daemon state management |
| 2 | | 2 | |
| 3 | use anyhow::{Context, Result}; | 3 | use anyhow::{Context, Result}; |
| | 4 | +use crossbeam_channel::Receiver; |
| 4 | use std::collections::HashMap; | 5 | use std::collections::HashMap; |
| | 6 | +use std::sync::Arc; |
| | 7 | +use std::thread::JoinHandle; |
| 5 | use std::time::Duration; | 8 | use std::time::Duration; |
| 6 | use tokio::net::UnixStream; | 9 | use tokio::net::UnixStream; |
| 7 | use tokio::signal::unix::{signal, SignalKind}; | 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 | /// Active animation state (works with GIF, WebP, or other animated formats) | 102 | /// Active animation state (works with GIF, WebP, or other animated formats) |
| 76 | pub struct ActiveAnimation { | 103 | pub struct ActiveAnimation { |
| 77 | - /// Pre-scaled frames for quick rendering | 104 | + /// Frame source (preloaded or streaming) |
| 78 | - scaled_frames: Vec<image::RgbaImage>, | 105 | + frame_source: FrameSource, |
| 79 | - /// Frame delays (parallel to scaled_frames) | | |
| 80 | - frame_delays: Vec<Duration>, | | |
| 81 | /// Animation renderer (double-buffered) | 106 | /// Animation renderer (double-buffered) |
| 82 | renderer: AnimationRenderer, | 107 | renderer: AnimationRenderer, |
| 83 | - /// Current frame index | | |
| 84 | - current_frame: usize, | | |
| 85 | /// Max FPS | 108 | /// Max FPS |
| 86 | max_fps: u32, | 109 | max_fps: u32, |
| 87 | /// Scale mode (for status) | 110 | /// Scale mode (for status) |
@@ -93,7 +116,7 @@ pub struct ActiveAnimation { |
| 93 | } | 116 | } |
| 94 | | 117 | |
| 95 | impl ActiveAnimation { | 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 | fn from_frames( | 120 | fn from_frames( |
| 98 | frames: &[AnimationFrame], | 121 | frames: &[AnimationFrame], |
| 99 | renderer: AnimationRenderer, | 122 | renderer: AnimationRenderer, |
@@ -102,14 +125,52 @@ impl ActiveAnimation { |
| 102 | source: String, | 125 | source: String, |
| 103 | screen_width: u32, | 126 | screen_width: u32, |
| 104 | screen_height: u32, | 127 | screen_height: u32, |
| | 128 | + memory_budget: u64, |
| 105 | ) -> Self { | 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 | let num_cpus = std::thread::available_parallelism() | 168 | let num_cpus = std::thread::available_parallelism() |
| 108 | .map(|n| n.get()) | 169 | .map(|n| n.get()) |
| 109 | .unwrap_or(4); | 170 | .unwrap_or(4); |
| | 171 | + |
| 110 | let scaled_frames: Vec<image::RgbaImage> = std::thread::scope(|s| { | 172 | let scaled_frames: Vec<image::RgbaImage> = std::thread::scope(|s| { |
| 111 | let mut results = Vec::with_capacity(frames.len()); | 173 | let mut results = Vec::with_capacity(frames.len()); |
| 112 | - // Process in batches of num_cpus to avoid spawning too many threads | | |
| 113 | for chunk in frames.chunks(num_cpus) { | 174 | for chunk in frames.chunks(num_cpus) { |
| 114 | let handles: Vec<_> = chunk | 175 | let handles: Vec<_> = chunk |
| 115 | .iter() | 176 | .iter() |
@@ -124,25 +185,68 @@ impl ActiveAnimation { |
| 124 | results | 185 | results |
| 125 | }); | 186 | }); |
| 126 | | 187 | |
| 127 | - let frame_delays: Vec<Duration> = frames | 188 | + let frame_delays: Vec<Duration> = frames.iter().map(|f| f.delay).collect(); |
| 128 | - .iter() | | |
| 129 | - .map(|frame| frame.delay) | | |
| 130 | - .collect(); | | |
| 131 | | 189 | |
| 132 | - Self { | 190 | + FrameSource::Preloaded { |
| 133 | scaled_frames, | 191 | scaled_frames, |
| 134 | frame_delays, | 192 | frame_delays, |
| 135 | - renderer, | | |
| 136 | current_frame: 0, | 193 | current_frame: 0, |
| 137 | - max_fps, | 194 | + } |
| 138 | - scale_mode, | 195 | + } |
| 139 | - source, | 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 | /// Get the delay for the current frame | 242 | /// Get the delay for the current frame |
| 144 | fn current_delay(&self) -> Duration { | 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 | let min_delay = if self.max_fps > 0 { | 250 | let min_delay = if self.max_fps > 0 { |
| 147 | Duration::from_secs_f64(1.0 / self.max_fps as f64) | 251 | Duration::from_secs_f64(1.0 / self.max_fps as f64) |
| 148 | } else { | 252 | } else { |
@@ -153,19 +257,41 @@ impl ActiveAnimation { |
| 153 | | 257 | |
| 154 | /// Advance to next frame, returning true if looped | 258 | /// Advance to next frame, returning true if looped |
| 155 | fn advance(&mut self) -> bool { | 259 | fn advance(&mut self) -> bool { |
| 156 | - self.current_frame += 1; | 260 | + match &mut self.frame_source { |
| 157 | - if self.current_frame >= self.scaled_frames.len() { | 261 | + FrameSource::Preloaded { scaled_frames, current_frame, .. } => { |
| 158 | - self.current_frame = 0; | 262 | + *current_frame += 1; |
| 159 | - true | 263 | + if *current_frame >= scaled_frames.len() { |
| 160 | - } else { | 264 | + *current_frame = 0; |
| 161 | - false | 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 | /// Get frame count | 288 | /// Get frame count |
| 166 | #[allow(dead_code)] | 289 | #[allow(dead_code)] |
| 167 | fn frame_count(&self) -> usize { | 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 | let anim = self.animation.as_mut() | 735 | let anim = self.animation.as_mut() |
| 610 | .ok_or_else(|| anyhow::anyhow!("No active animation"))?; | 736 | .ok_or_else(|| anyhow::anyhow!("No active animation"))?; |
| 611 | | 737 | |
| 612 | - // Render current frame | 738 | + // Render current frame (inline match for split borrowing of frame_source vs renderer) |
| 613 | - let frame = &anim.scaled_frames[anim.current_frame]; | 739 | + match &anim.frame_source { |
| 614 | - anim.renderer.render_and_present(conn, frame)?; | 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 | // Advance to next frame | 750 | // Advance to next frame |
| 617 | anim.advance(); | 751 | anim.advance(); |
@@ -995,13 +1129,19 @@ impl Daemon { |
| 995 | let renderer = AnimationRenderer::new(conn)?; | 1129 | let renderer = AnimationRenderer::new(conn)?; |
| 996 | let (width, height) = conn.screen_dimensions(); | 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 | tracing::info!( | 1133 | tracing::info!( |
| 999 | - "Animation loaded: {} frames, {:.1} FPS ({})", | 1134 | + "Animation loaded: {} frames, {:.1} FPS ({}), screen {}x{}, {:.1} MB/frame, {:.1} MB total", |
| 1000 | frame_count, | 1135 | frame_count, |
| 1001 | avg_fps, | 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 | self.animation = Some(ActiveAnimation::from_frames( | 1145 | self.animation = Some(ActiveAnimation::from_frames( |
| 1006 | &frames, | 1146 | &frames, |
| 1007 | renderer, | 1147 | renderer, |
@@ -1010,6 +1150,7 @@ impl Daemon { |
| 1010 | source.to_string(), | 1150 | source.to_string(), |
| 1011 | width as u32, | 1151 | width as u32, |
| 1012 | height as u32, | 1152 | height as u32, |
| | 1153 | + memory_budget, |
| 1013 | )); | 1154 | )); |
| 1014 | | 1155 | |
| 1015 | Ok(()) | 1156 | Ok(()) |
@@ -1067,12 +1208,18 @@ impl Daemon { |
| 1067 | let renderer = AnimationRenderer::new(conn)?; | 1208 | let renderer = AnimationRenderer::new(conn)?; |
| 1068 | let (width, height) = conn.screen_dimensions(); | 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 | tracing::info!( | 1212 | tracing::info!( |
| 1071 | - "Video loaded: {} frames, {:.1} FPS", | 1213 | + "Video loaded: {} frames, {:.1} FPS, screen {}x{}, {:.1} MB/frame, {:.1} MB total", |
| 1072 | frame_count, | 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 | self.animation = Some(ActiveAnimation::from_frames( | 1223 | self.animation = Some(ActiveAnimation::from_frames( |
| 1077 | &frames, | 1224 | &frames, |
| 1078 | renderer, | 1225 | renderer, |
@@ -1081,6 +1228,7 @@ impl Daemon { |
| 1081 | source.to_string(), | 1228 | source.to_string(), |
| 1082 | width as u32, | 1229 | width as u32, |
| 1083 | height as u32, | 1230 | height as u32, |
| | 1231 | + memory_budget, |
| 1084 | )); | 1232 | )); |
| 1085 | | 1233 | |
| 1086 | Ok(()) | 1234 | Ok(()) |