Skip to content

Commit 82d6806

Browse files
Merge pull request #18 from Luquatic/fix/oversized-wallpaper-recovery
fix(renderer): recover from oversized wallpapers
2 parents 3b0c574 + 427dfad commit 82d6806

9 files changed

Lines changed: 808 additions & 191 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
- **Fix dead FPS pacer in video playback**: the `--max-fps` pacing loop in the video path declared `last_present` without a type annotation and never assigned it, so the limit silently never activated and the crate failed to compile in a fresh package build. The variable is now typed and stamped after each presented frame.
1212

13+
- **Prevent oversized wallpaper restart loops**: static images are reduced to render-appropriate output dimensions before GPU upload, texture allocations are validated against the adapter limit, and wallpaper state is persisted only after a successful commit. Startup restoration can fall back to the previous valid wallpaper instead of repeatedly aborting on poisoned state.
14+
1315
## 0.3.0
1416

1517
- **Stabilize explicit-sync and NVDEC lifecycles** — merged in PR #13 from @Luquatic.

‎docs/architecture.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@
4040
- **Backend**: Vulkan / OpenGL / Metal (via `wgpu` abstraction)
4141
- **Shader Pipeline**: Single-pass WGSL shader (`effects.wgsl`)
4242
- **Uniform Buffer**: Tracks separate old/new image aspect ratios, screen resolution, animation progress (`0.0..1.0`), active effect type index (`fade`, `blur`, `wipe`, `slide`, `zoom`, `pixelate`, `ripple`, `dissolve`, `wave`, `grow`, `outer`), effect parameters (`param_a` to `param_d`), effect origin (`origin`), travel direction (`direction`), easing mode (`easing`: `0` linear, `1` ease-in, `2` ease-out, `3` ease-in-out), and scaling mode (`scaling_mode`: `0` fill, `1` fit, `2` stretch, `3` center, `4` tile). Struct is 80 bytes (`Vec2`-aligned, size padded for WGSL uniform layout).
43-
- **Aspect Correction**: Computes aspect-ratio scaling directly inside the fragment shader, avoiding CPU-side cropping or image scaling overhead. Five scaling modes are supported: `fill` (cover, crops to fill screen), `fit` (contain, letterbox/pillarbox), `stretch` (ignores aspect ratio), `center` (1:1 centered), and `tile` (repeat). The mode is passed to the GPU via a uniform and applied per-pixel in the `scale_uv` function. Circular effects (`grow`, `outer`, `ripple`) additionally convert UVs into pixel-aspect-corrected space before taking `distance()`, so expanding rings are true circles on any monitor, never ovals.
43+
- **Aspect Correction**: Static images larger than their useful render size are reduced once before upload while preserving enough pixels for the selected output and scaling mode. The fragment shader still owns the final aspect-ratio mapping: `fill` covers and crops, `fit` contains with letterboxing/pillarboxing, `stretch` ignores aspect ratio, `center` remains 1:1, and `tile` repeats. Center/tile sources retain native dimensions and return a clear error when they exceed the adapter limit rather than silently changing their pixel-sensitive scale. Circular effects (`grow`, `outer`, `ripple`) additionally convert UVs into pixel-aspect-corrected space before taking `distance()`, so expanding rings are true circles on any monitor, never ovals.
4444
- **Stable Image Registration**: The old and new source textures each keep their own immutable `fill` crop for the whole transition. Effects animate blend values and reveal masks in screen space; they do not translate or rescale the wallpaper texture. This prevents the visible “jump” that occurs when images with different source dimensions are changed mid-transition.
4545
- **Smoothness**: Every transition is eased with a configurable curve (`linear` / `ease_in` / `ease_out` / `ease_in_out`, default smoothstep cubic ease-in-out) and rendered one frame per vsync. The daemon presents with `PresentMode::Fifo`, so `get_current_texture()` blocks until the previous frame is displayed, pacing animations to the monitor refresh rate. Progress is derived from wall-clock time rather than a frame counter, so a transition lasts exactly its configured `duration` on any refresh rate (frame-count pacing would run too fast on high-refresh panels and too slow on low ones). The ease-in-out tail keeps visible motion almost to the last frame, so a blur radius or crossfade never appears to stagger to a halt before the transition finishes.
4646
- **Non-blocking transitions**: The daemon commits the new wallpaper state immediately and renders the visual transition on a detached background task, serialized by a render lock. `wallr set` returns as soon as the image is committed and themed, never waiting on GPU presents. If the compositor stops presenting (monitor off, suspend), the render task parks inside the present without freezing the IPC loop, and later transitions simply queue behind it.
4747
- **Live wallpapers**: when the committed file is an animated GIF, `AnimatedImage` decodes every frame once at load time. If the raw RGBA total fits the 256MB budget, frames are stored as-is and playback is a memcpy per frame; larger animations are stored as zstd-compressed streams (roughly 30:1) and decompressed through a persistent `zstd::bulk::Decompressor` context, so neither path ever re-decodes the source file during looping playback. The first frame becomes the transition's incoming texture, and when the transition ends the render task switches to playback: it computes the absolute wall-clock boundary of the next frame (`frame_start(index+1)` plus whole-loop offsets, so pacing survives animation wrap-around) and presents at that deadline, uploading the next frame into an idle double-buffered texture during the sleep via a mapped staging buffer (copied with `copy_buffer_to_texture`; unaligned widths fall back to `update_texture`). A playback generation counter is bumped on every commit, so a queued playback loop stops itself the moment a newer wallpaper supersedes it. GIF playback respects `wallr ipc pause/resume` commands and preserves timeline position when paused by tracking accumulated pause time. Static images skip playback entirely and present a single frame, and the preview window follows the same flow.
4848
- **Video playback**: `video::VideoPlayback` decodes MP4/WebM/MKV with FFmpeg. Hardware acceleration can be set to `auto` (tries all backends in priority order: NVDEC, VAAPI, VideoToolbox), a specific backend (tries that backend then falls back to software), or `software` (software-only, no hardware attempts). The active decoder backend is reported immediately after successful initialization. Frames are delivered on PTS timing through a small bounded queue and uploaded with the same texture pipeline; `wallpaper.loop_video` restarts the stream at EOF for seamless looping. `wallr ipc pause/resume/seek/info` control playback, and the video path is disabled for static images.
49-
- **Previous-frame compositing**: The daemon keeps the previous decoded wallpaper texture as the outgoing source and reveals the new texture over it. The persisted last wallpaper is restored at daemon startup, so a restart also has a real outgoing frame. The preview window reads the same persisted path and uses the last applied wallpaper as its outgoing frame, falling back to a solid black frame only when nothing was ever applied (or when the outgoing image is the same file as the incoming one).
49+
- **Previous-frame compositing**: The daemon keeps the previous decoded wallpaper texture as the outgoing source and reveals the new texture over it. Per-output state is atomically persisted only after the replacement commits, rotating the former path into a previous-wallpaper slot. Startup retries explicitly transient failures and falls back to that previous valid path when restoration still fails. The preview window reads the persisted path and uses the last applied wallpaper as its outgoing frame, falling back to a solid black frame only when nothing was ever applied (or when the outgoing image is the same file as the incoming one).
5050

5151
### 2b. Animation → Uniforms Path
5252
Every transition (from a YAML package, `wallr set --effect ...`, or the preview window) resolves to a single `animation::Effect` value, which `compute_effect_uniforms(effect, progress)` converts into an `EffectUniforms` struct (effect type, eased progress, `param_a` to `param_d`, origin, direction, easing mode). The daemon and preview both feed this into `Renderer::render_frame`, so CLI flags, YAML packages, and previews share one identical code path:

‎wallr-core/Cargo.toml‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ notify.workspace = true
6262
dirs.workspace = true
6363
glob.workspace = true
6464
which.workspace = true
65+
tempfile = "3"
6566

6667
# Utilities
6768
sha2.workspace = true
@@ -71,6 +72,3 @@ humansize.workspace = true
7172
# Video support
7273
ffmpeg-next.workspace = true
7374
crossbeam-channel.workspace = true
74-
75-
[dev-dependencies]
76-
tempfile = "3"

‎wallr-core/src/animated/mod.rs‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use gif::DisposalMethod;
1414
/// Maximum bytes of decoded frame data kept in RAM. Frames beyond this are
1515
/// still decoded on demand, but not cached across loop wraps.
1616
const CACHE_BUDGET: usize = 256 * 1024 * 1024;
17+
const MAX_GIF_WORKING_SET: usize = 512 * 1024 * 1024;
1718

1819
/// A cached frame: raw RGBA8 or zstd-compressed RGBA8. The whole animation
1920
/// uses one representation, chosen at decode time: raw when the full decoded
@@ -109,9 +110,8 @@ impl AnimatedImage {
109110
return Ok(None);
110111
}
111112
let total = info.delays.iter().copied().sum();
112-
let pixels = (info.width * info.height) as usize;
113+
let (pixels, raw_size) = gif_allocation_sizes(info.width, info.height, info.delays.len())?;
113114
let frame_count = info.delays.len();
114-
let raw_size = pixels * 4 * frame_count;
115115
let raw_cache = raw_size <= CACHE_BUDGET;
116116
if !raw_cache {
117117
tracing::debug!(
@@ -349,6 +349,27 @@ impl AnimatedImage {
349349
}
350350
}
351351

352+
fn gif_allocation_sizes(
353+
width: u32,
354+
height: u32,
355+
frame_count: usize,
356+
) -> anyhow::Result<(usize, usize)> {
357+
let pixels = usize::try_from(u64::from(width) * u64::from(height))?;
358+
let frame_bytes = pixels
359+
.checked_mul(4)
360+
.ok_or_else(|| anyhow::anyhow!("GIF frame size overflow for {width}x{height}"))?;
361+
let working_set = frame_bytes
362+
.checked_mul(3)
363+
.ok_or_else(|| anyhow::anyhow!("GIF working-set overflow for {width}x{height}"))?;
364+
anyhow::ensure!(
365+
working_set <= MAX_GIF_WORKING_SET,
366+
"GIF {width}x{height} requires approximately {:.1} MiB of decode working memory, exceeding the {} MiB safety limit",
367+
working_set as f64 / (1024.0 * 1024.0),
368+
MAX_GIF_WORKING_SET / 1024 / 1024
369+
);
370+
Ok((pixels, frame_bytes.saturating_mul(frame_count)))
371+
}
372+
352373
/// Parses GIF header blocks (screen descriptor, graphic control extensions,
353374
/// image descriptors) without decoding pixel data, returning the timeline.
354375
fn scan_gif(bytes: &[u8]) -> anyhow::Result<Option<GifInfo>> {
@@ -516,4 +537,10 @@ mod tests {
516537
assert!(scan_gif(b"not a gif at all").unwrap().is_none());
517538
assert!(scan_gif(&[0u8; 100]).unwrap().is_none());
518539
}
540+
541+
#[test]
542+
fn rejects_gif_working_sets_before_allocating_canvases() {
543+
assert!(gif_allocation_sizes(7_680, 4_320, 2).is_ok());
544+
assert!(gif_allocation_sizes(16_384, 16_384, 1).is_err());
545+
}
519546
}

0 commit comments

Comments
 (0)