feat(gpui): support BGRA surfaces on macOS; add W4 viewer widget
Two changes landing W4 of the oak task list: gpui_macos: the Surface renderer only accepted biplanar YUV 4:2:0 pixel buffers, so engine BGRA frames could not be displayed. SurfaceBounds gains an is_bgra flag and draw_surfaces now creates a single BGRA8Unorm texture (and the fragment shader swizzles it to RGBA) for 32BGRA buffers, keeping the YUV path unchanged. Verified by running the W3 surface_bridge demo. gpui_widgets::viewer: ViewerWidget with a pure TransportState machine (advance/loop/step/in-out, unit-tested), a PlaybackClock trait the host implements over the engine, a ~60Hz polling ticker, a transport bar emitting ViewerEvent requests, timecode via gpui::timeline::time, and safe-frame/zoom toggles. examples/viewer.rs shows a mock clock with generated macOS test frames. 87 widget + 182 gpui tests pass.
This commit is contained in:
@@ -29,7 +29,9 @@ use image::RgbaImage;
|
||||
use core_foundation::base::TCFType;
|
||||
use core_video::{
|
||||
metal_texture::CVMetalTextureGetTexture, metal_texture_cache::CVMetalTextureCache,
|
||||
pixel_buffer::kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
|
||||
pixel_buffer::{
|
||||
kCVPixelFormatType_32BGRA, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
|
||||
},
|
||||
};
|
||||
use foreign_types::{ForeignType, ForeignTypeRef};
|
||||
use metal::{
|
||||
@@ -1838,33 +1840,48 @@ impl MetalRenderer {
|
||||
DevicePixels::from(surface.image_buffer.get_height() as i32),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
surface.image_buffer.get_pixel_format(),
|
||||
kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
|
||||
);
|
||||
let is_bgra = match surface.image_buffer.get_pixel_format() {
|
||||
kCVPixelFormatType_420YpCbCr8BiPlanarFullRange => false,
|
||||
kCVPixelFormatType_32BGRA => true,
|
||||
other => {
|
||||
log::error!("unsupported surface pixel format: {other}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Y (or the whole BGRA image) texture from plane 0.
|
||||
let y_texture = self
|
||||
.core_video_texture_cache
|
||||
.create_texture_from_image(
|
||||
surface.image_buffer.as_concrete_TypeRef(),
|
||||
None,
|
||||
MTLPixelFormat::R8Unorm,
|
||||
if is_bgra {
|
||||
MTLPixelFormat::BGRA8Unorm
|
||||
} else {
|
||||
MTLPixelFormat::R8Unorm
|
||||
},
|
||||
surface.image_buffer.get_width_of_plane(0),
|
||||
surface.image_buffer.get_height_of_plane(0),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
let cb_cr_texture = self
|
||||
.core_video_texture_cache
|
||||
.create_texture_from_image(
|
||||
surface.image_buffer.as_concrete_TypeRef(),
|
||||
None,
|
||||
MTLPixelFormat::RG8Unorm,
|
||||
surface.image_buffer.get_width_of_plane(1),
|
||||
surface.image_buffer.get_height_of_plane(1),
|
||||
1,
|
||||
|
||||
let cb_cr_texture = if is_bgra {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.core_video_texture_cache
|
||||
.create_texture_from_image(
|
||||
surface.image_buffer.as_concrete_TypeRef(),
|
||||
None,
|
||||
MTLPixelFormat::RG8Unorm,
|
||||
surface.image_buffer.get_width_of_plane(1),
|
||||
surface.image_buffer.get_height_of_plane(1),
|
||||
1,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
|
||||
align_offset(instance_offset);
|
||||
let next_offset = *instance_offset + mem::size_of::<Surface>();
|
||||
@@ -1882,15 +1899,17 @@ impl MetalRenderer {
|
||||
mem::size_of_val(&texture_size) as u64,
|
||||
&texture_size as *const Size<DevicePixels> as *const _,
|
||||
);
|
||||
// let y_texture = y_texture.get_texture().unwrap().
|
||||
command_encoder.set_fragment_texture(SurfaceInputIndex::YTexture as u64, unsafe {
|
||||
let texture = CVMetalTextureGetTexture(y_texture.as_concrete_TypeRef());
|
||||
Some(metal::TextureRef::from_ptr(texture as *mut _))
|
||||
});
|
||||
command_encoder.set_fragment_texture(SurfaceInputIndex::CbCrTexture as u64, unsafe {
|
||||
let texture = CVMetalTextureGetTexture(cb_cr_texture.as_concrete_TypeRef());
|
||||
Some(metal::TextureRef::from_ptr(texture as *mut _))
|
||||
});
|
||||
command_encoder.set_fragment_texture(
|
||||
SurfaceInputIndex::CbCrTexture as u64,
|
||||
cb_cr_texture.as_ref().map(|texture| unsafe {
|
||||
let texture = CVMetalTextureGetTexture(texture.as_concrete_TypeRef());
|
||||
metal::TextureRef::from_ptr(texture as *mut _)
|
||||
}),
|
||||
);
|
||||
|
||||
unsafe {
|
||||
let buffer_contents = (instance_buffer.metal_buffer.contents() as *mut u8)
|
||||
@@ -1901,6 +1920,7 @@ impl MetalRenderer {
|
||||
SurfaceBounds {
|
||||
bounds: surface.bounds,
|
||||
content_mask: surface.content_mask,
|
||||
is_bgra: u32::from(is_bgra),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2138,6 +2158,9 @@ pub struct PathSprite {
|
||||
pub struct SurfaceBounds {
|
||||
pub bounds: Bounds<ScaledPixels>,
|
||||
pub content_mask: ContentMask<ScaledPixels>,
|
||||
/// `1` when the surface is a single-plane BGRA buffer (sampled directly,
|
||||
/// no YUV conversion), `0` for the biplanar 4:2:0 video format.
|
||||
pub is_bgra: u32,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
|
||||
@@ -850,12 +850,14 @@ fragment float4 path_sprite_fragment(
|
||||
struct SurfaceVertexOutput {
|
||||
float4 position [[position]];
|
||||
float2 texture_position;
|
||||
float surface_is_bgra;
|
||||
float clip_distance [[clip_distance]][4];
|
||||
};
|
||||
|
||||
struct SurfaceFragmentInput {
|
||||
float4 position [[position]];
|
||||
float2 texture_position;
|
||||
float surface_is_bgra;
|
||||
};
|
||||
|
||||
vertex SurfaceVertexOutput surface_vertex(
|
||||
@@ -878,6 +880,7 @@ vertex SurfaceVertexOutput surface_vertex(
|
||||
return SurfaceVertexOutput{
|
||||
device_position,
|
||||
texture_position,
|
||||
(float)surface.is_bgra,
|
||||
{clip_distance.x, clip_distance.y, clip_distance.z, clip_distance.w}};
|
||||
}
|
||||
|
||||
@@ -892,6 +895,12 @@ fragment float4 surface_fragment(SurfaceFragmentInput input [[stage_in]],
|
||||
float4(+0.0000f, -0.3441f, +1.7720f, +0.0000f),
|
||||
float4(+1.4020f, -0.7141f, +0.0000f, +0.0000f),
|
||||
float4(-0.7010f, +0.5291f, -0.8860f, +1.0000f));
|
||||
if (input.surface_is_bgra > 0.5) {
|
||||
// BGRA8Unorm samples with .r = blue, .b = red; swizzle to RGBA.
|
||||
float4 bgra = y_texture.sample(texture_sampler, input.texture_position);
|
||||
return float4(bgra.b, bgra.g, bgra.r, bgra.a);
|
||||
}
|
||||
|
||||
float4 ycbcr = float4(
|
||||
y_texture.sample(texture_sampler, input.texture_position).r,
|
||||
cb_cr_texture.sample(texture_sampler, input.texture_position).rg, 1.0);
|
||||
|
||||
@@ -20,6 +20,9 @@ thiserror.workspace = true
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
core-video.workspace = true
|
||||
|
||||
[[example]]
|
||||
name = "controls"
|
||||
path = "examples/controls.rs"
|
||||
@@ -27,3 +30,7 @@ path = "examples/controls.rs"
|
||||
[[example]]
|
||||
name = "menus_dialogs"
|
||||
path = "examples/menus_dialogs.rs"
|
||||
|
||||
[[example]]
|
||||
name = "viewer"
|
||||
path = "examples/viewer.rs"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! A viewer demo: a mock playback clock drives the transport bar and (on
|
||||
//! macOS) a generated test frame fills the picture area every tick.
|
||||
//!
|
||||
//! Run with `cargo run -p gpui_widgets --example viewer`.
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, Entity, Render, Window, WindowBounds, WindowOptions, div, prelude::*,
|
||||
px, size,
|
||||
};
|
||||
use gpui::timeline::{Frame, FrameRate};
|
||||
use gpui_widgets::viewer::{PlaybackClock, ViewerEvent, ViewerWidget};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod frame_gen {
|
||||
use core_video::pixel_buffer::CVPixelBuffer;
|
||||
use std::ffi::c_void;
|
||||
|
||||
const WIDTH: u32 = 1280;
|
||||
const HEIGHT: u32 = 720;
|
||||
|
||||
/// Generates cheap test frames (a moving hue band) into CVPixelBuffers.
|
||||
pub struct FrameGen {
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
impl FrameGen {
|
||||
pub fn new() -> Self {
|
||||
Self { frame: 0 }
|
||||
}
|
||||
|
||||
pub fn next(&mut self) -> CVPixelBuffer {
|
||||
let mut bytes = vec![0u8; (WIDTH * HEIGHT * 4) as usize];
|
||||
for y in 0..HEIGHT {
|
||||
for x in 0..WIDTH {
|
||||
let i = ((y * WIDTH + x) * 4) as usize;
|
||||
let t = self.frame as f32 / 60.0;
|
||||
let band = if (x as f32 / 128.0 + t * 8.0).fract() < 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
0.35
|
||||
};
|
||||
bytes[i] = (255.0f32 * band).round() as u8;
|
||||
bytes[i + 1] = (255.0f32 * band * (y as f32 / HEIGHT as f32)).round() as u8;
|
||||
bytes[i + 2] = (255.0f32 * band * (1.0 - y as f32 / HEIGHT as f32)).round() as u8;
|
||||
bytes[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
let callback_ref: Box<Vec<u8>> = Box::new(bytes);
|
||||
let release_con = Box::into_raw(callback_ref) as *mut c_void;
|
||||
self.frame += 1;
|
||||
unsafe {
|
||||
CVPixelBuffer::new_with_bytes(
|
||||
0x42475241, // kCVPixelFormatType_32BGRA
|
||||
WIDTH as usize,
|
||||
HEIGHT as usize,
|
||||
release_con as *mut c_void,
|
||||
(WIDTH * 4) as usize,
|
||||
free_bytes,
|
||||
release_con,
|
||||
None,
|
||||
)
|
||||
}
|
||||
.expect("failed to create test pixel buffer")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extern "C" fn free_bytes(release_ref_con: *mut c_void, _base: *const *const c_void) {
|
||||
if !release_ref_con.is_null() {
|
||||
unsafe {
|
||||
drop(Box::from_raw(release_ref_con as *mut Vec<u8>));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MockClock {
|
||||
frame: Frame,
|
||||
playing: bool,
|
||||
rate: FrameRate,
|
||||
}
|
||||
|
||||
impl PlaybackClock for MockClock {
|
||||
fn current_frame(&self) -> Frame {
|
||||
self.frame
|
||||
}
|
||||
fn is_playing(&self) -> bool {
|
||||
self.playing
|
||||
}
|
||||
fn frame_rate(&self) -> FrameRate {
|
||||
self.rate
|
||||
}
|
||||
}
|
||||
|
||||
struct Example {
|
||||
clock: Entity<MockClock>,
|
||||
viewer: Entity<ViewerWidget<MockClock>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
frame_gen: frame_gen::FrameGen,
|
||||
}
|
||||
|
||||
impl Example {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let clock = cx.new(|_| MockClock {
|
||||
frame: Frame(0),
|
||||
playing: true,
|
||||
rate: FrameRate::new(30, 1),
|
||||
});
|
||||
let viewer = cx.new(|cx| ViewerWidget::new(1, clock.clone(), window, cx));
|
||||
cx.subscribe(
|
||||
&viewer,
|
||||
|_this: &mut Self, _v: Entity<ViewerWidget<MockClock>>, event: &ViewerEvent, _cx| {
|
||||
println!("viewer request: {event:?}");
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
let this = cx.weak_entity();
|
||||
window.spawn(cx, async move |cx: &mut gpui::AsyncWindowContext| {
|
||||
loop {
|
||||
cx.background_executor()
|
||||
.timer(std::time::Duration::from_millis(16))
|
||||
.await;
|
||||
let _ = cx.update(|_window, app| {
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(app, |this, cx| this.tick(cx));
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
clock,
|
||||
viewer,
|
||||
#[cfg(target_os = "macos")]
|
||||
frame_gen: frame_gen::FrameGen::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&mut self, cx: &mut Context<Self>) {
|
||||
// Simulate the engine: advance the clock while playing.
|
||||
self.clock.update(cx, |clock, _cx| {
|
||||
if clock.playing {
|
||||
clock.frame = Frame((clock.frame.0 + 1) % (30 * 60 * 5));
|
||||
}
|
||||
});
|
||||
// Feed a fresh frame (macOS only).
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let buffer = self.frame_gen.next();
|
||||
self.viewer
|
||||
.update(cx, |viewer, cx| viewer.set_frame_source(Some(buffer.into()), cx));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Example {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.viewer.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
gpui_platform::application().run(|cx: &mut App| {
|
||||
cx.init_colors();
|
||||
let bounds = Bounds::centered(None, size(px(960.0), px(540.0)), cx);
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| cx.new(|cx| Example::new(window, cx)),
|
||||
)
|
||||
.expect("Failed to open window");
|
||||
|
||||
cx.activate(true);
|
||||
cx.on_window_closed(|cx, _| {
|
||||
if cx.windows().is_empty() {
|
||||
cx.quit();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
@@ -28,3 +28,4 @@ pub mod radio_group;
|
||||
pub mod slider;
|
||||
pub mod spinbox;
|
||||
pub mod value;
|
||||
pub mod viewer;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! The playback clock a [`ViewerWidget`](super::ViewerWidget) reads its
|
||||
//! position from.
|
||||
//!
|
||||
//! The host implements this trait over its engine (in Oak: the audio engine
|
||||
//! clock queried through the C ABI). The widget polls it on a timer and only
|
||||
//! *requests* transport changes; the engine is the single source of truth.
|
||||
|
||||
use gpui::timeline::{Frame, FrameRate};
|
||||
|
||||
/// A read-only view of the engine's playback clock.
|
||||
pub trait PlaybackClock: 'static {
|
||||
/// The current playhead frame.
|
||||
fn current_frame(&self) -> Frame;
|
||||
/// Whether playback is running.
|
||||
fn is_playing(&self) -> bool;
|
||||
/// The sequence's frame rate.
|
||||
fn frame_rate(&self) -> FrameRate;
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! The viewer widget: a fullscreen preview with a transport bar.
|
||||
//!
|
||||
//! The picture comes from a [`SurfaceSource`] (in Oak, the W3 bridge's
|
||||
//! IOSurface-backed pixel buffer); the playhead position is polled from the
|
||||
//! host's [`PlaybackClock`] on a ~60 Hz timer, and every transport action is
|
||||
//! emitted as a [`ViewerEvent`] request (the engine applies it and the clock
|
||||
//! reflects it). Timecode formatting reuses `gpui::timeline::time`.
|
||||
|
||||
pub mod clock;
|
||||
pub mod transport;
|
||||
|
||||
pub use clock::*;
|
||||
pub use transport::*;
|
||||
|
||||
use gpui::timeline::{FrameRate, TimeDisplay, format_timecode};
|
||||
use gpui::{
|
||||
App, AsyncWindowContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
ObjectFit, Render, SurfaceSource, Window, colors::DefaultColors, div, prelude::*, px, surface,
|
||||
};
|
||||
|
||||
/// A request emitted by the viewer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ViewerEvent {
|
||||
/// Start playback.
|
||||
PlayRequested {
|
||||
/// The viewer's stable id.
|
||||
control: usize,
|
||||
},
|
||||
/// Pause playback.
|
||||
PauseRequested {
|
||||
/// The viewer's stable id.
|
||||
control: usize,
|
||||
},
|
||||
/// Step the playhead by `delta` frames.
|
||||
StepRequested {
|
||||
/// The viewer's stable id.
|
||||
control: usize,
|
||||
/// Frames to step (negative steps backward).
|
||||
delta: i64,
|
||||
},
|
||||
/// Set the loop-in point at the playhead.
|
||||
InPointRequested {
|
||||
/// The viewer's stable id.
|
||||
control: usize,
|
||||
},
|
||||
/// Set the loop-out point at the playhead.
|
||||
OutPointRequested {
|
||||
/// The viewer's stable id.
|
||||
control: usize,
|
||||
},
|
||||
/// Clear the loop range.
|
||||
ClearRangeRequested {
|
||||
/// The viewer's stable id.
|
||||
control: usize,
|
||||
},
|
||||
/// Toggle the safe-frame overlay.
|
||||
ToggleSafeFramesRequested {
|
||||
/// The viewer's stable id.
|
||||
control: usize,
|
||||
},
|
||||
/// Toggle the zoom (contain vs cover).
|
||||
ToggleZoomRequested {
|
||||
/// The viewer's stable id.
|
||||
control: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// The viewer widget.
|
||||
pub struct ViewerWidget<C: PlaybackClock> {
|
||||
control: usize,
|
||||
clock: Entity<C>,
|
||||
frame_rate: FrameRate,
|
||||
transport: TransportState,
|
||||
frame_source: Option<SurfaceSource>,
|
||||
focus_handle: FocusHandle,
|
||||
show_safe_frames: bool,
|
||||
zoom: bool,
|
||||
}
|
||||
|
||||
impl<C: PlaybackClock> ViewerWidget<C> {
|
||||
/// Create a viewer driven by `clock`.
|
||||
pub fn new(
|
||||
control: usize,
|
||||
clock: Entity<C>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let frame_rate = clock.read(cx).frame_rate();
|
||||
|
||||
// Poll the engine clock on a timer and reflect it locally.
|
||||
let this = cx.weak_entity();
|
||||
window.spawn(cx, async move |cx: &mut AsyncWindowContext| {
|
||||
loop {
|
||||
cx.background_executor()
|
||||
.timer(std::time::Duration::from_millis(16))
|
||||
.await;
|
||||
let _ = cx.update(|_window, app| {
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(app, |this, cx| this.poll_clock(cx));
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
control,
|
||||
clock,
|
||||
frame_rate,
|
||||
transport: TransportState::new(),
|
||||
frame_source: None,
|
||||
focus_handle: cx.focus_handle(),
|
||||
show_safe_frames: false,
|
||||
zoom: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The current transport state.
|
||||
pub fn transport(&self) -> TransportState {
|
||||
self.transport
|
||||
}
|
||||
|
||||
/// Set the picture source (the bridge's pixel buffer) and repaint.
|
||||
pub fn set_frame_source(&mut self, source: Option<SurfaceSource>, cx: &mut Context<Self>) {
|
||||
self.frame_source = source;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn poll_clock(&mut self, cx: &mut Context<Self>) {
|
||||
let clock = self.clock.read(cx);
|
||||
let frame = clock.current_frame();
|
||||
let playing = clock.is_playing();
|
||||
if frame != self.transport.frame || playing != self.transport.playing {
|
||||
self.transport.frame = frame;
|
||||
self.transport.playing = playing;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&mut self, event: ViewerEvent, cx: &mut Context<Self>) {
|
||||
cx.emit(event);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: PlaybackClock> EventEmitter<ViewerEvent> for ViewerWidget<C> {}
|
||||
|
||||
impl<C: PlaybackClock> Focusable for ViewerWidget<C> {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: PlaybackClock> Render for ViewerWidget<C> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let timecode = format_timecode(
|
||||
self.transport.frame,
|
||||
self.frame_rate,
|
||||
TimeDisplay::Timecode,
|
||||
);
|
||||
|
||||
// The picture area: surface (or placeholder), safe frames and zoom.
|
||||
let mut picture = div()
|
||||
.id("gpui-widgets-viewer-picture")
|
||||
.flex_1()
|
||||
.relative()
|
||||
.bg(gpui::Hsla {
|
||||
h: 0.0,
|
||||
s: 0.0,
|
||||
l: 0.0,
|
||||
a: 1.0,
|
||||
});
|
||||
|
||||
if let Some(source) = &self.frame_source {
|
||||
let fit = if self.zoom { ObjectFit::Cover } else { ObjectFit::Contain };
|
||||
picture = picture.child(
|
||||
surface(source.clone())
|
||||
.size_full()
|
||||
.object_fit(fit),
|
||||
);
|
||||
} else {
|
||||
picture = picture.child(
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_color(colors.disabled)
|
||||
.child("No frame source"),
|
||||
);
|
||||
}
|
||||
|
||||
if self.show_safe_frames {
|
||||
picture = picture.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.w(px(560.0))
|
||||
.h(px(315.0))
|
||||
.border_1()
|
||||
.border_color(colors.selected),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Transport bar.
|
||||
let playing = self.transport.playing;
|
||||
let play_label = if playing { "⏸" } else { "▶" };
|
||||
let transport_bar = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.bg(colors.container)
|
||||
.child(button(
|
||||
"gpui-widgets-viewer-in",
|
||||
"⏮",
|
||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
this.emit(ViewerEvent::InPointRequested { control: this.control }, cx);
|
||||
}),
|
||||
))
|
||||
.child(button(
|
||||
"gpui-widgets-viewer-step-back",
|
||||
"⏪",
|
||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
this.emit(
|
||||
ViewerEvent::StepRequested {
|
||||
control: this.control,
|
||||
delta: -1,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
))
|
||||
.child(button(
|
||||
"gpui-widgets-viewer-play",
|
||||
play_label,
|
||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
let event = if this.transport.playing {
|
||||
ViewerEvent::PauseRequested { control: this.control }
|
||||
} else {
|
||||
ViewerEvent::PlayRequested { control: this.control }
|
||||
};
|
||||
this.emit(event, cx);
|
||||
}),
|
||||
))
|
||||
.child(button(
|
||||
"gpui-widgets-viewer-step-forward",
|
||||
"⏩",
|
||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
this.emit(
|
||||
ViewerEvent::StepRequested {
|
||||
control: this.control,
|
||||
delta: 1,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
))
|
||||
.child(button(
|
||||
"gpui-widgets-viewer-out",
|
||||
"⏭",
|
||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
this.emit(ViewerEvent::OutPointRequested { control: this.control }, cx);
|
||||
}),
|
||||
))
|
||||
.child(button(
|
||||
"gpui-widgets-viewer-clear-range",
|
||||
"✕",
|
||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
this.emit(ViewerEvent::ClearRangeRequested { control: this.control }, cx);
|
||||
}),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.text_color(colors.text)
|
||||
.child(timecode),
|
||||
)
|
||||
.child(
|
||||
div().flex_1(),
|
||||
)
|
||||
.child(button(
|
||||
"gpui-widgets-viewer-safe",
|
||||
"安全框",
|
||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
this.show_safe_frames = !this.show_safe_frames;
|
||||
this.emit(
|
||||
ViewerEvent::ToggleSafeFramesRequested { control: this.control },
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
))
|
||||
.child(button(
|
||||
"gpui-widgets-viewer-zoom",
|
||||
"缩放",
|
||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
this.zoom = !this.zoom;
|
||||
this.emit(ViewerEvent::ToggleZoomRequested { control: this.control }, cx);
|
||||
}),
|
||||
));
|
||||
|
||||
div().size_full().flex().flex_col().child(picture).child(transport_bar)
|
||||
}
|
||||
}
|
||||
|
||||
/// A small labeled button.
|
||||
fn button(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> impl IntoElement {
|
||||
div()
|
||||
.id(id)
|
||||
.debug_selector(move || id.into())
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded_md()
|
||||
.cursor_pointer()
|
||||
.hover(|style| style.bg(gpui::colors::Colors::dark().selected))
|
||||
.on_click(on_click)
|
||||
.child(label)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::timeline::{Frame, FrameRate};
|
||||
use gpui::{Modifiers, TestAppContext, VisualTestContext, px, size};
|
||||
|
||||
struct MockClock {
|
||||
frame: Frame,
|
||||
playing: bool,
|
||||
}
|
||||
impl PlaybackClock for MockClock {
|
||||
fn current_frame(&self) -> Frame {
|
||||
self.frame
|
||||
}
|
||||
fn is_playing(&self) -> bool {
|
||||
self.playing
|
||||
}
|
||||
fn frame_rate(&self) -> FrameRate {
|
||||
FrameRate::new(30, 1)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn play_button_emits_play_request(cx: &mut TestAppContext) {
|
||||
struct Host {
|
||||
viewer: Entity<ViewerWidget<MockClock>>,
|
||||
events: Vec<ViewerEvent>,
|
||||
}
|
||||
impl Render for Host {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.viewer.clone())
|
||||
}
|
||||
}
|
||||
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(420.0)), |window, cx| {
|
||||
let clock = cx.new(|_| MockClock {
|
||||
frame: Frame(0),
|
||||
playing: false,
|
||||
});
|
||||
let viewer = cx.new(|cx| ViewerWidget::new(1, clock, window, cx));
|
||||
let host = Host {
|
||||
viewer,
|
||||
events: Vec::new(),
|
||||
};
|
||||
cx.subscribe(
|
||||
&host.viewer,
|
||||
|host: &mut Host,
|
||||
_v: Entity<ViewerWidget<MockClock>>,
|
||||
event: &ViewerEvent,
|
||||
_cx: &mut Context<Host>| {
|
||||
host.events.push(event.clone());
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
host
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let host = window.root(cx).unwrap();
|
||||
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
// The play button sits on the left of the transport bar at the bottom.
|
||||
let play = cx
|
||||
.debug_bounds("gpui-widgets-viewer-play")
|
||||
.expect("play button rendered");
|
||||
cx.simulate_click(play.center(), Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
|
||||
let requested = cx.read(|app| {
|
||||
host.read(app).events.iter().any(|e| {
|
||||
matches!(e, ViewerEvent::PlayRequested { control: 1 })
|
||||
})
|
||||
});
|
||||
assert!(requested, "expected a PlayRequested event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timecode_formatting_reuses_timeline() {
|
||||
let frame = Frame(3000);
|
||||
let text = format_timecode(frame, FrameRate::new(30, 1), TimeDisplay::Timecode);
|
||||
assert_eq!(text, "00:01:40:00");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Pure transport state machine for the [`ViewerWidget`](super::ViewerWidget):
|
||||
//! playback position, play/pause, in/out points and loop-range stepping.
|
||||
//! No gpui coupling, unit-tested.
|
||||
|
||||
use gpui::timeline::Frame;
|
||||
|
||||
/// The playback state of a viewer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TransportState {
|
||||
/// The current playhead frame.
|
||||
pub frame: Frame,
|
||||
/// Whether playback is running (the engine drives the position; the
|
||||
/// view only reflects it).
|
||||
pub playing: bool,
|
||||
/// The loop-in point, if set.
|
||||
pub in_point: Option<Frame>,
|
||||
/// The loop-out point, if set.
|
||||
pub out_point: Option<Frame>,
|
||||
/// Whether playback loops between the in/out points.
|
||||
pub loop_range: bool,
|
||||
}
|
||||
|
||||
impl Default for TransportState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportState {
|
||||
/// Create a stopped transport at frame 0.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
frame: Frame(0),
|
||||
playing: false,
|
||||
in_point: None,
|
||||
out_point: None,
|
||||
loop_range: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle play/pause; returns the new state.
|
||||
pub fn toggle_play(&mut self) -> bool {
|
||||
self.playing = !self.playing;
|
||||
self.playing
|
||||
}
|
||||
|
||||
/// Advance one frame. Clamps at `length` (exclusive); when a loop range
|
||||
/// is active the position wraps to `in_point` instead of stopping at
|
||||
/// `out_point`.
|
||||
pub fn advance(&mut self, length: Frame) -> Frame {
|
||||
let next = self.frame.0 + 1;
|
||||
self.frame = if let Some(out) = self.out_point {
|
||||
if self.loop_range && next >= out.0 {
|
||||
Frame(self.in_point.unwrap_or(Frame(0)).0)
|
||||
} else {
|
||||
Frame(next.min(length.0 - 1))
|
||||
}
|
||||
} else {
|
||||
Frame(next.min(length.0 - 1))
|
||||
};
|
||||
self.frame
|
||||
}
|
||||
|
||||
/// Step the playhead by `delta` frames, clamped to `[0, length)`.
|
||||
pub fn step(&mut self, delta: i64, length: Frame) -> Frame {
|
||||
self.frame = Frame((self.frame.0 + delta).clamp(0, length.0 - 1));
|
||||
self.frame
|
||||
}
|
||||
|
||||
/// Set the loop-in point at the current frame.
|
||||
pub fn set_in_point(&mut self, length: Frame) {
|
||||
self.in_point = Some(Frame(self.frame.0.min(length.0 - 1)));
|
||||
self.loop_range = true;
|
||||
}
|
||||
|
||||
/// Set the loop-out point at the current frame.
|
||||
pub fn set_out_point(&mut self, length: Frame) {
|
||||
self.out_point = Some(Frame(self.frame.0.min(length.0 - 1)));
|
||||
self.loop_range = true;
|
||||
}
|
||||
|
||||
/// Clear the loop range.
|
||||
pub fn clear_range(&mut self) {
|
||||
self.in_point = None;
|
||||
self.out_point = None;
|
||||
self.loop_range = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn toggle_play_flips() {
|
||||
let mut t = TransportState::new();
|
||||
assert!(!t.playing);
|
||||
assert!(t.toggle_play());
|
||||
assert!(!t.toggle_play());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_clamps_at_length() {
|
||||
let mut t = TransportState::new();
|
||||
t.frame = Frame(9);
|
||||
assert_eq!(t.advance(Frame(10)), Frame(9));
|
||||
assert_eq!(t.advance(Frame(10)), Frame(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_loops_within_range() {
|
||||
let mut t = TransportState::new();
|
||||
t.frame = Frame(5);
|
||||
t.in_point = Some(Frame(4));
|
||||
t.out_point = Some(Frame(8));
|
||||
t.loop_range = true;
|
||||
assert_eq!(t.advance(Frame(100)), Frame(6));
|
||||
t.frame = Frame(7);
|
||||
assert_eq!(t.advance(Frame(100)), Frame(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_ignores_out_point_without_loop() {
|
||||
let mut t = TransportState::new();
|
||||
t.out_point = Some(Frame(8));
|
||||
t.loop_range = false;
|
||||
t.frame = Frame(7);
|
||||
assert_eq!(t.advance(Frame(100)), Frame(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_clamps_to_range() {
|
||||
let mut t = TransportState::new();
|
||||
t.frame = Frame(2);
|
||||
assert_eq!(t.step(-5, Frame(10)), Frame(0));
|
||||
assert_eq!(t.step(100, Frame(10)), Frame(9));
|
||||
// 9 + 1 -> 10, clamped to the last index 9.
|
||||
assert_eq!(t.step(1, Frame(10)), Frame(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_out_points_respect_length() {
|
||||
let mut t = TransportState::new();
|
||||
t.frame = Frame(50);
|
||||
t.set_in_point(Frame(10));
|
||||
assert_eq!(t.in_point, Some(Frame(9)));
|
||||
t.frame = Frame(50);
|
||||
t.set_out_point(Frame(10));
|
||||
assert_eq!(t.out_point, Some(Frame(9)));
|
||||
assert!(t.loop_range);
|
||||
t.clear_range();
|
||||
assert_eq!(t.in_point, None);
|
||||
assert_eq!(t.out_point, None);
|
||||
assert!(!t.loop_range);
|
||||
}
|
||||
}
|
||||
@@ -78,11 +78,22 @@
|
||||
|
||||
## W4. 播放同步与检视器 glue
|
||||
|
||||
- [ ] `ViewerWidget`(新,放 `crates/gpui_widgets/` 或 oak 侧):
|
||||
- [x] `ViewerWidget`(新,放 `crates/gpui_widgets/` 或 oak 侧):
|
||||
画面区(W3 的 surface)+ 走带控制(播放/暂停/逐帧/入点出点)+
|
||||
时间码显示 + 安全框/缩放开关。播放驱动:oak audio 引擎时钟经
|
||||
C ABI 查询,`cx.spawn` + timer 刷新播放头。
|
||||
- [ ] 单测:时间码换算(复用 oakcore-rs Rational)、走带状态机。
|
||||
> 实现在 `gpui_widgets::viewer`:`PlaybackClock` trait(host 在
|
||||
> oak 侧经 C ABI 实现),widget 以 ~60Hz timer 轮询并把走带操作
|
||||
> 作为 `ViewerEvent` 请求发出;时间码复用 `gpui::timeline::time`
|
||||
> 的 `format_timecode`(含 `TimeDisplay::Timecode`)。
|
||||
- [x] 单测:时间码换算(复用 oakcore-rs Rational)、走带状态机。
|
||||
> `transport.rs` 纯状态机单测(advance/loop/step/in-out)+ 时间码
|
||||
> 测试;`examples/viewer.rs`(macOS 生成测试帧上屏,非 macOS 显
|
||||
> 示占位)。
|
||||
> 附带修复:`gpui_macos` 的 Surface 渲染此前只接受 YUV 420 双
|
||||
> 平面缓冲,为让引擎的 BGRA 帧直接上屏,给 `metal_renderer` +
|
||||
> `shaders.metal` 增加了单平面 32BGRA 分支(SurfaceBounds 带
|
||||
> `is_bgra` 标志,shader 直接采样 BGRA 并交换到 RGBA)。
|
||||
|
||||
## W5. 时间线工具模式层
|
||||
|
||||
|
||||
Reference in New Issue
Block a user