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
8 changes: 5 additions & 3 deletions subwave_wayland/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Wayland subsurface-based video output for Iced with HDR passthrough support.
- [x] GStreamer pipeline with waylandsink
- [x] Wayland display context sharing
- [x] Pre-commit hook synchronization for position updates
- [x] `ContentFit` geometry for Contain, Cover, Fill, None, and ScaleDown
- [x] Zero-copy video rendering with hardware acceleration

## Architecture
Expand All @@ -32,6 +33,7 @@ This crate implements video playback using Wayland subsurfaces, which allows:
- Iced widget that reserves space in layout
- Accesses WaylandIntegration via thread-local storage
- Updates subsurface position based on widget bounds
- Maps Iced `ContentFit` modes to GStreamer's render rectangle

3. **Pipeline** (`pipeline.rs`)
- Dynamic pipeline creation
Expand Down Expand Up @@ -86,8 +88,7 @@ This approach ensures proper video display and prevents transparency issues, but
### Next Steps
1. Subtitle management
- Requires upstream gstreamer changes
2. Add content fit support through aspect ratio pipeline element
3. Playback speed integration
2. Playback speed integration

## Usage

Expand All @@ -104,7 +105,8 @@ let video = SubsurfaceVideo::new(&url).expect("Failed to create video");
// Build an Iced widget to reserve layout space and drive updates
let player = VideoPlayer::new(&video)
.width(iced::Length::Fill)
.height(iced::Length::Fill);
.height(iced::Length::Fill)
.content_fit(iced::ContentFit::Contain);
```

Note: Keep the `SubsurfaceVideo` alive for the duration of playback.
Expand Down
156 changes: 156 additions & 0 deletions subwave_wayland/src/geometry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
use iced::{ContentFit, Size};

/// Video destination relative to the widget's Wayland surface.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct VideoRectangle {
pub(crate) x: i32,
pub(crate) y: i32,
pub(crate) width: i32,
pub(crate) height: i32,
}

impl VideoRectangle {
pub(crate) fn fill(width: i32, height: i32) -> Self {
Self {
x: 0,
y: 0,
width,
height,
}
}
}

/// Resolve an Iced content-fit mode into a Wayland render rectangle.
///
/// The rectangle is relative to the video widget. Modes are centered except
/// for Iced's native-size `None` mode, which remains anchored at the top-left.
/// `Cover` can intentionally produce negative offsets and dimensions larger
/// than the widget so the compositor clips the excess at the top-level surface
/// boundary.
pub(crate) fn fit_video_rectangle(
content_fit: ContentFit,
source_width: i32,
source_height: i32,
target_width: i32,
target_height: i32,
) -> Option<VideoRectangle> {
if source_width <= 0 || source_height <= 0 || target_width <= 0 || target_height <= 0 {
return None;
}

let source = Size::new(source_width as f32, source_height as f32);
let target = Size::new(target_width as f32, target_height as f32);
let fitted = content_fit.fit(source, target);

if !fitted.width.is_finite()
|| !fitted.height.is_finite()
|| fitted.width <= 0.0
|| fitted.height <= 0.0
|| fitted.width > i32::MAX as f32
|| fitted.height > i32::MAX as f32
{
return None;
}

let width = (fitted.width.round() as i32).max(1);
let height = (fitted.height.round() as i32).max(1);
let (x, y) = if content_fit == ContentFit::None {
(0, 0)
} else {
(
((target_width - width) as f64 / 2.0).round() as i32,
((target_height - height) as f64 / 2.0).round() as i32,
)
};

Some(VideoRectangle {
x,
y,
width,
height,
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn contain_upscales_1080p_to_4k() {
assert_eq!(
fit_video_rectangle(ContentFit::Contain, 1920, 1080, 3840, 2160),
Some(VideoRectangle::fill(3840, 2160))
);
}

#[test]
fn contain_letterboxes_and_centers() {
assert_eq!(
fit_video_rectangle(ContentFit::Contain, 1440, 1080, 3840, 2160),
Some(VideoRectangle {
x: 480,
y: 0,
width: 2880,
height: 2160,
})
);
}

#[test]
fn cover_crops_and_centers() {
assert_eq!(
fit_video_rectangle(ContentFit::Cover, 1440, 1080, 3840, 2160),
Some(VideoRectangle {
x: 0,
y: -360,
width: 3840,
height: 2880,
})
);
}

#[test]
fn fill_stretches_to_target() {
assert_eq!(
fit_video_rectangle(ContentFit::Fill, 1440, 1080, 3840, 2160),
Some(VideoRectangle::fill(3840, 2160))
);
}

#[test]
fn none_keeps_native_size_at_top_left() {
assert_eq!(
fit_video_rectangle(ContentFit::None, 1920, 1080, 3840, 2160),
Some(VideoRectangle::fill(1920, 1080))
);
}

#[test]
fn scale_down_never_upscales() {
assert_eq!(
fit_video_rectangle(ContentFit::ScaleDown, 1920, 1080, 3840, 2160),
Some(VideoRectangle {
x: 960,
y: 540,
width: 1920,
height: 1080,
})
);
assert_eq!(
fit_video_rectangle(ContentFit::ScaleDown, 3840, 2160, 1920, 1080),
Some(VideoRectangle::fill(1920, 1080))
);
}

#[test]
fn rejects_non_positive_dimensions() {
assert_eq!(
fit_video_rectangle(ContentFit::Contain, 0, 1080, 3840, 2160),
None
);
assert_eq!(
fit_video_rectangle(ContentFit::Contain, 1920, 1080, 3840, 0),
None
);
}
}
2 changes: 2 additions & 0 deletions subwave_wayland/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#[cfg(target_os = "linux")]
pub mod color_management;
#[cfg(target_os = "linux")]
mod geometry;
#[cfg(target_os = "linux")]
pub mod gstplayflags;
#[cfg(target_os = "linux")]
pub mod internal;
Expand Down
26 changes: 23 additions & 3 deletions subwave_wayland/src/subsurface_manager.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::{Error, Result, WaylandIntegration};
use crate::{geometry::VideoRectangle, Error, Result, WaylandIntegration};
use parking_lot::Mutex;
use std::io::Write;
use std::os::fd::AsFd;
Expand Down Expand Up @@ -63,9 +63,12 @@ pub struct WaylandSubsurfaceManager {
/// Current position relative to parent
position: Arc<Mutex<(i32, i32)>>,

/// Current size
/// Current widget/canvas size.
size: Arc<Mutex<(i32, i32)>>,

/// Current GStreamer destination relative to the widget canvas.
video_rectangle: Mutex<Option<VideoRectangle>>,

/// Flag indicating we need to update on next parent commit
needs_update: Arc<AtomicBool>,

Expand Down Expand Up @@ -100,6 +103,7 @@ impl std::fmt::Debug for WaylandSubsurfaceManager {
f.debug_struct("WaylandVideoSubsurface")
.field("position", &self.position.lock())
.field("size", &self.size.lock())
.field("video_rectangle", &self.video_rectangle.lock())
.field(
"needs_update",
&self.needs_update.load(std::sync::atomic::Ordering::Relaxed),
Expand Down Expand Up @@ -400,6 +404,7 @@ impl WaylandSubsurfaceManager {
subtitle_viewport,
position: Arc::new(Mutex::new((0, 0))),
size: Arc::new(Mutex::new((0, 0))),
video_rectangle: Mutex::new(None),
needs_update: Arc::new(AtomicBool::new(false)),
shm: Some(shm),
video_anchor_buffer,
Expand Down Expand Up @@ -696,11 +701,19 @@ impl WaylandSubsurfaceManager {
*self.position.lock()
}

/// Get the current size
/// Get the current widget/canvas size.
pub fn get_size(&self) -> (i32, i32) {
*self.size.lock()
}

pub(crate) fn set_video_rectangle(&self, rectangle: VideoRectangle) {
*self.video_rectangle.lock() = Some(rectangle);
}

pub(crate) fn get_video_rectangle(&self) -> Option<VideoRectangle> {
*self.video_rectangle.lock()
}

// Do we have use for this function?
pub fn set_buffer_offset(&self, x: i32, y: i32) {
self.video_surface.offset(x, y);
Expand Down Expand Up @@ -798,6 +811,13 @@ impl WaylandSubsurfaceManager {
Ok(())
}

/// Commit child-subsurface geometry requested by GStreamer.
///
/// This does not damage, resize, or retag the transparent mapping anchor.
pub(crate) fn commit_video_host_state(&self) {
self.video_surface.commit();
}

/// Force the mutable overlay surfaces to redraw.
///
/// The video host is intentionally omitted because its transparent 1x1
Expand Down
Loading
Loading