diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs index 0e2a408564..4ff9ac4e6e 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -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::(); @@ -1882,15 +1899,17 @@ impl MetalRenderer { mem::size_of_val(&texture_size) as u64, &texture_size as *const Size 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, pub content_mask: ContentMask, + /// `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"))] diff --git a/crates/gpui_macos/src/shaders.metal b/crates/gpui_macos/src/shaders.metal index 155ceae162..211229c466 100644 --- a/crates/gpui_macos/src/shaders.metal +++ b/crates/gpui_macos/src/shaders.metal @@ -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); diff --git a/crates/gpui_widgets/Cargo.toml b/crates/gpui_widgets/Cargo.toml index f8d1eb6942..c306a90794 100644 --- a/crates/gpui_widgets/Cargo.toml +++ b/crates/gpui_widgets/Cargo.toml @@ -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" diff --git a/crates/gpui_widgets/examples/viewer.rs b/crates/gpui_widgets/examples/viewer.rs new file mode 100644 index 0000000000..be9c21375e --- /dev/null +++ b/crates/gpui_widgets/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> = 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)); + } + } + } +} + +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, + viewer: Entity>, + #[cfg(target_os = "macos")] + frame_gen: frame_gen::FrameGen, +} + +impl Example { + fn new(window: &mut Window, cx: &mut Context) -> 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>, 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) { + // 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) -> 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(); + }); +} diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs index ca0e1b5255..44a53e2f95 100644 --- a/crates/gpui_widgets/src/lib.rs +++ b/crates/gpui_widgets/src/lib.rs @@ -28,3 +28,4 @@ pub mod radio_group; pub mod slider; pub mod spinbox; pub mod value; +pub mod viewer; diff --git a/crates/gpui_widgets/src/viewer/clock.rs b/crates/gpui_widgets/src/viewer/clock.rs new file mode 100644 index 0000000000..1c2f4f1622 --- /dev/null +++ b/crates/gpui_widgets/src/viewer/clock.rs @@ -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; +} diff --git a/crates/gpui_widgets/src/viewer/mod.rs b/crates/gpui_widgets/src/viewer/mod.rs new file mode 100644 index 0000000000..10788f1408 --- /dev/null +++ b/crates/gpui_widgets/src/viewer/mod.rs @@ -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 { + control: usize, + clock: Entity, + frame_rate: FrameRate, + transport: TransportState, + frame_source: Option, + focus_handle: FocusHandle, + show_safe_frames: bool, + zoom: bool, +} + +impl ViewerWidget { + /// Create a viewer driven by `clock`. + pub fn new( + control: usize, + clock: Entity, + window: &mut Window, + cx: &mut Context, + ) -> 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, cx: &mut Context) { + self.frame_source = source; + cx.notify(); + } + + fn poll_clock(&mut self, cx: &mut Context) { + 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) { + cx.emit(event); + cx.notify(); + } +} + +impl EventEmitter for ViewerWidget {} + +impl Focusable for ViewerWidget { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ViewerWidget { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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>, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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>, + event: &ViewerEvent, + _cx: &mut Context| { + 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"); + } +} diff --git a/crates/gpui_widgets/src/viewer/transport.rs b/crates/gpui_widgets/src/viewer/transport.rs new file mode 100644 index 0000000000..a2243b0996 --- /dev/null +++ b/crates/gpui_widgets/src/viewer/transport.rs @@ -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, + /// The loop-out point, if set. + pub out_point: Option, + /// 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); + } +} diff --git a/docs/zh/oak-app-rewrite.md b/docs/zh/oak-app-rewrite.md index df7121be10..79a96489a7 100644 --- a/docs/zh/oak-app-rewrite.md +++ b/docs/zh/oak-app-rewrite.md @@ -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. 时间线工具模式层