Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

- **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.

- **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.

## 0.3.0

- **Stabilize explicit-sync and NVDEC lifecycles** — merged in PR #13 from @Luquatic.
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@
- **Backend**: Vulkan / OpenGL / Metal (via `wgpu` abstraction)
- **Shader Pipeline**: Single-pass WGSL shader (`effects.wgsl`)
- **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).
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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).
- **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).

### 2b. Animation → Uniforms Path
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:
Expand Down
4 changes: 1 addition & 3 deletions wallr-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ notify.workspace = true
dirs.workspace = true
glob.workspace = true
which.workspace = true
tempfile = "3"

# Utilities
sha2.workspace = true
Expand All @@ -71,6 +72,3 @@ humansize.workspace = true
# Video support
ffmpeg-next.workspace = true
crossbeam-channel.workspace = true

[dev-dependencies]
tempfile = "3"
31 changes: 29 additions & 2 deletions wallr-core/src/animated/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use gif::DisposalMethod;
/// Maximum bytes of decoded frame data kept in RAM. Frames beyond this are
/// still decoded on demand, but not cached across loop wraps.
const CACHE_BUDGET: usize = 256 * 1024 * 1024;
const MAX_GIF_WORKING_SET: usize = 512 * 1024 * 1024;

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

fn gif_allocation_sizes(
width: u32,
height: u32,
frame_count: usize,
) -> anyhow::Result<(usize, usize)> {
let pixels = usize::try_from(u64::from(width) * u64::from(height))?;
let frame_bytes = pixels
.checked_mul(4)
.ok_or_else(|| anyhow::anyhow!("GIF frame size overflow for {width}x{height}"))?;
let working_set = frame_bytes
.checked_mul(3)
.ok_or_else(|| anyhow::anyhow!("GIF working-set overflow for {width}x{height}"))?;
anyhow::ensure!(
working_set <= MAX_GIF_WORKING_SET,
"GIF {width}x{height} requires approximately {:.1} MiB of decode working memory, exceeding the {} MiB safety limit",
working_set as f64 / (1024.0 * 1024.0),
MAX_GIF_WORKING_SET / 1024 / 1024
);
Ok((pixels, frame_bytes.saturating_mul(frame_count)))
}

/// Parses GIF header blocks (screen descriptor, graphic control extensions,
/// image descriptors) without decoding pixel data, returning the timeline.
fn scan_gif(bytes: &[u8]) -> anyhow::Result<Option<GifInfo>> {
Expand Down Expand Up @@ -516,4 +537,10 @@ mod tests {
assert!(scan_gif(b"not a gif at all").unwrap().is_none());
assert!(scan_gif(&[0u8; 100]).unwrap().is_none());
}

#[test]
fn rejects_gif_working_sets_before_allocating_canvases() {
assert!(gif_allocation_sizes(7_680, 4_320, 2).is_ok());
assert!(gif_allocation_sizes(16_384, 16_384, 1).is_err());
}
}
Loading
Loading