From 7b295b76616e424e5e73348d5931cc97ea8224c8 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 11 Aug 2026 17:04:42 +0800 Subject: [PATCH] feat(app): scopes (histogram/waveform/vectorscope) fed by rendered frames - program viewer gains Picture/Scopes tabs; the scopes page hosts the gpui_widgets histogram, waveform and vectorscope side by side - RealEngine analyzes the same F32 RGBA samples it renders (BT.709 luma, normalized Cb/Cr); the mock engine analyzes its synthetic frame through the same path; results ride the per-frame cache - AppEngine::scope_data(monitor) exposes ScopeData to panels - unit tests for the analysis math + a gpui test rendering the scopes tab; zh/en i18n keys added --- src/i18n.rs | 12 ++ src/oakui/engine.rs | 8 ++ src/oakui/frames.rs | 18 +-- src/oakui/mock.rs | 36 ++++-- src/oakui/mod.rs | 3 +- src/oakui/real.rs | 62 +++++++--- src/oakui/scopes.rs | 156 +++++++++++++++++++++++ src/panels/program_viewer.rs | 232 ++++++++++++++++++++++++++++++++--- 8 files changed, 473 insertions(+), 54 deletions(-) create mode 100644 src/oakui/scopes.rs diff --git a/src/i18n.rs b/src/i18n.rs index 1021a48f5..55ba98432 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -269,6 +269,12 @@ const EN: &[(&str, &str)] = &[ // --- viewer header chips --- ("viewer.source", "Source Viewer · Source"), ("viewer.program", "Program Viewer · Program"), + // --- program viewer tabs and scope labels --- + ("viewer.picture", "Picture"), + ("viewer.scopes", "Scopes"), + ("scope.histogram", "Histogram"), + ("scope.waveform", "Waveform"), + ("scope.vectorscope", "Vectorscope"), // --- viewer transport tooltips --- ("viewer.in_point", "Set In Point"), ("viewer.step_back", "Previous Frame"), @@ -409,6 +415,12 @@ const ZH: &[(&str, &str)] = &[ // --- viewer header chips --- ("viewer.source", "素材查看器 · 源"), ("viewer.program", "序列查看器 · 节目"), + // --- program viewer tabs and scope labels --- + ("viewer.picture", "画面"), + ("viewer.scopes", "示波器"), + ("scope.histogram", "直方图"), + ("scope.waveform", "波形图"), + ("scope.vectorscope", "矢量示波器"), // --- viewer transport tooltips --- ("viewer.in_point", "设置入点"), ("viewer.step_back", "上一帧"), diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index 014b374bc..e964fa385 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -47,6 +47,8 @@ use gpui_widgets::audio_meter::AudioMeterDataSource; use gpui_widgets::project_explorer::ProjectDataSource; use gpui_widgets::viewer::PlaybackClock; +pub use super::scopes::ScopeData; + /// A monitor the transport can address. /// /// Oak has two independent transports: the source monitor plays the clip @@ -181,6 +183,12 @@ pub trait AppEngine: /// frame, so a paused viewer never regenerates its picture). fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc; + /// The scope samples ([`ScopeData`]) of `monitor`'s current CPU frame. + /// The analysis runs inside the frame render pass (cached per playhead + /// frame alongside the image), so this read is an `Arc` clone and never + /// re-walks the frame. + fn scope_data(&self, monitor: Monitor, cx: &App) -> ScopeData; + /// Adds a new empty track of the given kind (undoable where the backend /// supports it). fn add_track(&mut self, kind: TrackKind, cx: &mut Context); diff --git a/src/oakui/frames.rs b/src/oakui/frames.rs index c24297137..35327d470 100644 --- a/src/oakui/frames.rs +++ b/src/oakui/frames.rs @@ -32,17 +32,19 @@ pub(crate) const SYNTH_FRAME_WIDTH: u32 = 384; /// Height of the synthetic test frame. pub(crate) const SYNTH_FRAME_HEIGHT: u32 = 216; -/// Generates a synthetic test frame: SMPTE-style color bars with a white -/// sweep whose x position follows `frame`, so transport playback shows up as -/// motion across the picture. +/// Generates the F32 RGBA samples of the synthetic test frame: SMPTE-style +/// color bars with a white sweep whose x position follows `frame`, so +/// transport playback shows up as motion across the picture. /// -/// Samples are computed as F32 RGBA (mirroring the real engine's pixel -/// pipeline) and downconverted to BGRA8 for the viewer's CPU-frame path. -pub(crate) fn synthetic_frame(frame: Frame) -> RenderImage { +/// The samples mirror the real engine's pixel format; callers downconvert +/// them to BGRA8 for the viewer's CPU-frame path and analyze the scope +/// samples from the very same buffer, so the scopes read exactly what the +/// viewer displays. +pub(crate) fn synthetic_frame_samples(frame: Frame) -> (u32, u32, Vec) { let width = SYNTH_FRAME_WIDTH; let height = SYNTH_FRAME_HEIGHT; - // F32 RGBA samples, then quantized to BGRA8 for the sprite atlas. + // F32 RGBA samples; the caller downconverts to BGRA8 for the sprite atlas. let mut samples = vec![0.0f32; (width * height * 4) as usize]; // SMPTE bars: 75% white, yellow, cyan, green, magenta, red, blue. let bars: [(f32, f32, f32); 7] = [ @@ -84,7 +86,7 @@ pub(crate) fn synthetic_frame(frame: Frame) -> RenderImage { } } - f32_rgba_to_bgra_image(width, height, &samples) + (width, height, samples) } /// Downconverts an F32 RGBA frame (the engine pipeline's pixel format) to a diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index d8f2f6d3d..d5ac7a525 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -62,7 +62,8 @@ use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry}; use gpui_widgets::viewer::PlaybackClock; use super::engine::{ - AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, Sequence, VideoFormat, + AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence, + VideoFormat, }; use super::transport::TransportState; @@ -422,9 +423,10 @@ pub struct MockEngine { /// effect stack) so both views share one selection. node_selection: BTreeSet, /// Cache of the synthetic CPU frames handed to the viewers, keyed by - /// monitor. Entries are the playhead frame that produced the image, so a - /// paused viewer never regenerates its picture. - cpu_frame_cache: Mutex)>>, + /// monitor. Entries are the playhead frame that produced the image plus + /// the scope samples analyzed in the same pass, so a paused viewer never + /// regenerates its picture (or its scopes). + cpu_frame_cache: Mutex, ScopeData)>>, } impl MockEngine { @@ -1070,6 +1072,10 @@ impl AppEngine for MockEngine { self.cpu_frame(monitor, cx) } + fn scope_data(&self, monitor: Monitor, cx: &App) -> ScopeData { + self.scope_data(monitor, cx) + } + fn add_track(&mut self, kind: TrackKind, cx: &mut Context) { self.add_track(kind, cx); } @@ -1408,15 +1414,31 @@ impl MockEngine { pub fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc { let frame = self.clock_frame(monitor, cx); let mut cache = self.cpu_frame_cache.lock().unwrap(); - if let Some((cached_frame, image)) = cache.get(&monitor) { + if let Some((cached_frame, image, _)) = cache.get(&monitor) { if *cached_frame == frame.0 { return image.clone(); } } - let image = Arc::new(crate::oakui::frames::synthetic_frame(frame)); - cache.insert(monitor, (frame.0, image.clone())); + let (width, height, samples) = crate::oakui::frames::synthetic_frame_samples(frame); + // Analyze the scopes from the same F32 samples the viewer displays. + let scope = crate::oakui::scopes::analyze_f32_rgba(width, height, &samples); + let image = Arc::new(crate::oakui::frames::f32_rgba_to_bgra_image(width, height, &samples)); + cache.insert(monitor, (frame.0, image.clone(), scope)); image } + + /// The scope samples of `monitor`'s current frame, from the same cache + /// [`MockEngine::cpu_frame`] fills (the analysis runs in the frame + /// generation pass, so this never re-walks a frame). + pub fn scope_data(&self, monitor: Monitor, cx: &App) -> ScopeData { + // Ensure the cache holds the current playhead frame. + let _ = self.cpu_frame(monitor, cx); + let cache = self.cpu_frame_cache.lock().unwrap(); + cache + .get(&monitor) + .map(|(_, _, scope)| scope.clone()) + .unwrap_or_default() + } } #[cfg(test)] diff --git a/src/oakui/mod.rs b/src/oakui/mod.rs index 2d9e777b7..0cccdd3b7 100644 --- a/src/oakui/mod.rs +++ b/src/oakui/mod.rs @@ -45,11 +45,12 @@ mod host_syms; pub mod icons; pub mod mock; pub mod real; +pub mod scopes; pub mod timecode; pub mod transport; pub use engine::{ - AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, Monitor, Project, + AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence, VideoFormat, }; pub use mock::{MockClock, MockEngine}; diff --git a/src/oakui/real.rs b/src/oakui/real.rs index d44430896..585cb2568 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -85,9 +85,11 @@ use gpui_widgets::viewer::PlaybackClock; use super::ffi::*; use super::engine::{ - AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, Sequence, VideoFormat, + AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence, + VideoFormat, }; -use super::frames::{f32_rgba_to_bgra_image, synthetic_frame}; +use super::frames::{f32_rgba_to_bgra_image, synthetic_frame_samples}; +use super::scopes::analyze_f32_rgba; use super::transport::TransportState; /// `oakengine_timeline.h` track-type constants. @@ -591,12 +593,13 @@ pub struct RealEngine { /// Phase counter driving the (silent) audio levels. meter_phase: u32, /// Cache of the CPU frames handed to the viewers, keyed by monitor. - /// Entries are the playhead frame that produced the image, so a paused - /// viewer never regenerates its picture. The program monitor's entries + /// Entries are the playhead frame that produced the image plus the scope + /// samples analyzed in the same pass, so a paused viewer never + /// regenerates its picture (or its scopes). The program monitor's entries /// are real rendered frames (see [`RealEngine::render_program_frame`]); /// the source monitor's are the synthetic pattern (the facade renderer /// binds a sequence only — footage-node rendering is a documented gap). - cpu_frame_cache: Mutex)>>, + cpu_frame_cache: Mutex, ScopeData)>>, /// The program monitor's cached renderer, created lazily from the /// current sequence at a proxy resolution. The mutex both provides the /// interior mutability `cpu_frame` (a `&self` read) needs and serializes @@ -697,11 +700,11 @@ impl RealEngine { /// Renders one program-monitor frame through the facade CPU renderer: /// creates the per-sequence renderer lazily (cached in `self.renderer`), - /// renders `frame`, and downconverts the F32 RGBA result to BGRA8. - /// Returns `None` (the caller falls back to the synthetic pattern) when - /// no sequence is open, the render manager is unavailable, or the render - /// itself fails. - fn render_program_frame(&self, frame: Frame) -> Option { + /// renders `frame`, analyzes the scope samples from the F32 RGBA result, + /// and downconverts to BGRA8. Returns `None` (the caller falls back to + /// the synthetic pattern) when no sequence is open, the render manager is + /// unavailable, or the render itself fails. + fn render_program_frame(&self, frame: Frame) -> Option<(RenderImage, ScopeData)> { let seq = self.seq_ptr()?; if !Self::ensure_render_manager() { return None; @@ -757,7 +760,9 @@ impl RealEngine { ); } } - image = Some(f32_rgba_to_bgra_image(width as u32, height as u32, &samples)); + // The scopes read the same F32 samples the viewer displays. + let scope = analyze_f32_rgba(width as u32, height as u32, &samples); + image = Some((f32_rgba_to_bgra_image(width as u32, height as u32, &samples), scope)); } unsafe { oakengine_frame_free(frame_ptr); @@ -1238,7 +1243,7 @@ impl AppEngine for RealEngine { fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc { let frame = self.clock_frame(monitor, cx); let mut cache = self.cpu_frame_cache.lock().unwrap(); - if let Some((cached_frame, image)) = cache.get(&monitor) { + if let Some((cached_frame, image, _)) = cache.get(&monitor) { if *cached_frame == frame.0 { return image.clone(); } @@ -1249,17 +1254,36 @@ impl AppEngine for RealEngine { // pattern: the facade renderer binds a *sequence* handle only, so // there is currently no surface to render a single footage node for // the material viewer — that is a documented facade gap. - let image = match monitor { - Monitor::Program => self - .render_program_frame(frame) - .map(Arc::new) - .unwrap_or_else(|| Arc::new(synthetic_frame(frame))), - Monitor::Source => Arc::new(synthetic_frame(frame)), + let rendered = match monitor { + Monitor::Program => self.render_program_frame(frame), + Monitor::Source => None, }; - cache.insert(monitor, (frame.0, image.clone())); + let (image, scope) = match rendered { + Some((image, scope)) => (Arc::new(image), scope), + None => { + let (width, height, samples) = synthetic_frame_samples(frame); + let scope = analyze_f32_rgba(width, height, &samples); + ( + Arc::new(f32_rgba_to_bgra_image(width, height, &samples)), + scope, + ) + } + }; + cache.insert(monitor, (frame.0, image.clone(), scope)); image } + fn scope_data(&self, monitor: Monitor, cx: &App) -> ScopeData { + // Ensure the cache holds the current playhead frame (the analysis + // runs inside that render pass, so this never re-walks a frame). + let _ = self.cpu_frame(monitor, cx); + let cache = self.cpu_frame_cache.lock().unwrap(); + cache + .get(&monitor) + .map(|(_, _, scope)| scope.clone()) + .unwrap_or_default() + } + fn add_track(&mut self, kind: TrackKind, cx: &mut Context) { let Some(seq) = self.seq_ptr() else { return; diff --git a/src/oakui/scopes.rs b/src/oakui/scopes.rs new file mode 100644 index 000000000..61cbd34c8 --- /dev/null +++ b/src/oakui/scopes.rs @@ -0,0 +1,156 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Scope analysis for the viewer scopes: derives the luma / chroma sample +//! streams the `gpui_widgets::scopes` widgets graph from an F32 RGBA frame +//! (the engine pipeline's pixel format). +//! +//! The analysis runs once per rendered frame, inside the same pass that +//! already touches every sample for the viewer downconvert, so a paused +//! viewer costs nothing and no frame is ever walked twice. The scope widgets +//! own the graphing math (histogram binning, waveform envelopes, vectorscope +//! projection); this module only turns pixels into their input samples. + +use std::sync::Arc; + +/// BT.709 luma coefficients. +const KR: f32 = 0.2126; +const KG: f32 = 0.7152; +const KB: f32 = 0.0722; + +/// The scope samples of one frame: luma per pixel for the histogram / +/// waveform, `(Cb, Cr)` per pixel for the vectorscope. +/// +/// Cheap to clone: both streams sit behind an [`Arc`], so handing the data +/// from the engine's frame cache to a panel copies two pointers. +#[derive(Debug, Clone, Default)] +pub struct ScopeData { + /// Per-pixel luma in `0..=1` (BT.709), row-major. + pub luma: Arc>, + /// Per-pixel chroma `(Cb, Cr)` in `0..=1`, centered on `0.5`. + pub chroma: Arc>, +} + +/// Analyzes one F32 RGBA frame into its [`ScopeData`]. `samples` must hold +/// exactly `width * height * 4` tightly packed values (the same contract as +/// [`super::frames::f32_rgba_to_bgra_image`]). +/// +/// Out-of-gamut samples are clamped into `0..=1` per channel first, so the +/// scopes read the same values the viewer displays. +pub(crate) fn analyze_f32_rgba(width: u32, height: u32, samples: &[f32]) -> ScopeData { + assert_eq!( + samples.len(), + (width * height * 4) as usize, + "F32 RGBA frame must be tightly packed" + ); + let pixels = (width * height) as usize; + let mut luma = Vec::with_capacity(pixels); + let mut chroma = Vec::with_capacity(pixels); + for i in (0..samples.len()).step_by(4) { + let r = samples[i].clamp(0.0, 1.0); + let g = samples[i + 1].clamp(0.0, 1.0); + let b = samples[i + 2].clamp(0.0, 1.0); + let y = KR * r + KG * g + KB * b; + // Cb/Cr normalized to 0..=1 (centered on 0.5) from the BT.709 + // coefficients: Cb = (B - Y) / (2(1 - Kb)) + 0.5, Cr likewise. + let cb = 0.5 + (b - y) / (2.0 * (1.0 - KB)); + let cr = 0.5 + (r - y) / (2.0 * (1.0 - KR)); + luma.push(y); + chroma.push((cb.clamp(0.0, 1.0), cr.clamp(0.0, 1.0))); + } + ScopeData { + luma: Arc::new(luma), + chroma: Arc::new(chroma), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gray_pixels_have_neutral_chroma() { + // One mid-gray pixel: luma equals the channel value, chroma is + // neutral (0.5, 0.5). + let samples = [0.5, 0.5, 0.5, 1.0]; + let data = analyze_f32_rgba(1, 1, &samples); + assert!((data.luma[0] - 0.5).abs() < 1e-6); + assert!((data.chroma[0].0 - 0.5).abs() < 1e-6); + assert!((data.chroma[0].1 - 0.5).abs() < 1e-6); + } + + #[test] + fn pure_primaries_have_known_luma_and_chroma() { + // Red, green, blue pixels in one row. + let samples = [ + 1.0, 0.0, 0.0, 1.0, // red + 0.0, 1.0, 0.0, 1.0, // green + 0.0, 0.0, 1.0, 1.0, // blue + ]; + let data = analyze_f32_rgba(3, 1, &samples); + assert!((data.luma[0] - KR).abs() < 1e-6); + assert!((data.luma[1] - KG).abs() < 1e-6); + assert!((data.luma[2] - KB).abs() < 1e-6); + // Pure red: Cb = 0.5 - Kr / (2(1 - Kb)), Cr saturates to 1.0. + assert!((data.chroma[0].0 - (0.5 - KR / (2.0 * (1.0 - KB)))).abs() < 1e-6); + assert!((data.chroma[0].1 - 1.0).abs() < 1e-6); + // Pure blue is the mirror: Cb saturates to 1.0, Cr dives. + assert!((data.chroma[2].0 - 1.0).abs() < 1e-6); + } + + #[test] + fn out_of_gamut_samples_clamp_like_the_viewer() { + // A super-white and a negative channel clamp to the displayed value. + let samples = [2.0, 2.0, 2.0, 1.0, -1.0, -1.0, -1.0, 1.0]; + let data = analyze_f32_rgba(2, 1, &samples); + assert!((data.luma[0] - 1.0).abs() < 1e-6); + assert!((data.luma[1] - 0.0).abs() < 1e-6); + } + + #[test] + fn analyzed_samples_feed_the_scope_math() { + // A black top half and a white bottom half: the histogram puts every + // sample into the two edge bins, and the waveform envelope (slicing + // the row-major luma stream) rises from black to white across the + // columns. + let width = 8u32; + let height = 4u32; + let mut samples = vec![0.0f32; (width * height * 4) as usize]; + for y in 0..height { + for x in 0..width { + let i = ((y * width + x) * 4) as usize; + let v = if y >= height / 2 { 1.0 } else { 0.0 }; + samples[i] = v; + samples[i + 1] = v; + samples[i + 2] = v; + samples[i + 3] = 1.0; + } + } + let data = analyze_f32_rgba(width, height, &samples); + + let bins = gpui_widgets::scopes::histogram_bins(&data.luma, 4); + assert_eq!(bins, vec![16, 0, 0, 16]); + + let envelope = gpui_widgets::scopes::waveform_envelope(&data.luma, 2); + assert_eq!(envelope.len(), 2); + assert!((envelope[0].0 - 0.0).abs() < 1e-6 && (envelope[0].1 - 0.0).abs() < 1e-6); + assert!((envelope[1].0 - 1.0).abs() < 1e-6 && (envelope[1].1 - 1.0).abs() < 1e-6); + + // Neutral gray chroma projects to the vectorscope's center. + let points = gpui_widgets::scopes::vectorscope_points(&data.chroma); + assert!(points.iter().all(|&(u, v)| u.abs() < 1e-6 && v.abs() < 1e-6)); + } +} diff --git a/src/panels/program_viewer.rs b/src/panels/program_viewer.rs index e9ca0586b..1121f7ff0 100644 --- a/src/panels/program_viewer.rs +++ b/src/panels/program_viewer.rs @@ -16,15 +16,18 @@ //! The program viewer panel (序列查看器): the `ViewerWidget` over the //! program monitor's clock, with a 26px audio level strip attached to its -//! right edge (the design's WP6 layout). +//! right edge (the design's WP6 layout). A header tab row switches the body +//! between the picture and the scopes (histogram / waveform / vectorscope), +//! whose samples come from the same rendered frame the picture shows. use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::{ - div, prelude::*, px, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, - Window, + div, prelude::*, px, AnyElement, App, ClickEvent, Context, Entity, EventEmitter, Render, + SharedString, Window, }; use gpui_widgets::audio_meter::AudioLevelMeter; +use gpui_widgets::scopes::{ChromaDataSource, Histogram, LumaDataSource, Vectorscope, Waveform}; use gpui_widgets::viewer::{ViewerEvent, ViewerWidget}; use crate::oakui::timecode::{format_fps, format_resolution}; @@ -35,6 +38,37 @@ use crate::panels::ids::PROGRAM_VIEWER; /// Width of the audio level strip, per the design (26px). const METER_WIDTH: f32 = 26.0; +/// The body tab of the program viewer: the picture or the scopes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProgramViewTab { + /// The rendered picture (the viewer widget plus the level strip). + Picture, + /// The scopes (histogram / waveform / vectorscope). + Scopes, +} + +/// The program monitor's scope samples, refreshed from the engine whenever +/// the displayed frame changes. The scope widgets read this entity through +/// the [`LumaDataSource`] / [`ChromaDataSource`] traits. +struct ScopeState { + /// Per-pixel luma of the current frame (`0..=1`). + luma: Vec, + /// Per-pixel chroma `(Cb, Cr)` of the current frame (`0..=1`). + chroma: Vec<(f32, f32)>, +} + +impl LumaDataSource for ScopeState { + fn luma_samples(&self) -> Vec { + self.luma.clone() + } +} + +impl ChromaDataSource for ScopeState { + fn chroma_samples(&self) -> Vec<(f32, f32)> { + self.chroma.clone() + } +} + /// The program viewer panel. pub struct ProgramViewerPanel { viewer: Entity>, @@ -43,6 +77,16 @@ pub struct ProgramViewerPanel { /// The last CPU frame handed to the viewer (compared by `Arc` identity so /// a paused playhead does not re-upload the picture every frame). last_cpu_frame: Option>, + /// The active body tab. + tab: ProgramViewTab, + /// The scope samples backing the three scope widgets. + scope_state: Entity, + /// The histogram scope. + histogram: Entity>, + /// The waveform scope. + waveform: Entity>, + /// The vectorscope. + vectorscope: Entity>, } impl ProgramViewerPanel { @@ -68,26 +112,73 @@ impl ProgramViewerPanel { }) .detach(); + let scope_state = cx.new(|_cx| ScopeState { + luma: Vec::new(), + chroma: Vec::new(), + }); + let histogram = cx.new(|cx| Histogram::new(41, scope_state.clone(), window, cx)); + let waveform = cx.new(|cx| Waveform::new(42, scope_state.clone(), window, cx)); + let vectorscope = cx.new(|cx| Vectorscope::new(43, scope_state.clone(), window, cx)); + Self { viewer, meter, engine, last_cpu_frame: None, + tab: ProgramViewTab::Picture, + scope_state, + histogram, + waveform, + vectorscope, } } - /// Pushes the engine's synthetic test frame into the viewer, but only when - /// it actually changed (the engine caches one image per playhead frame). + /// Pushes the engine's current frame into the viewer and the scopes, but + /// only when it actually changed (the engine caches one image per + /// playhead frame, with the scope samples analyzed in the same pass). fn sync_frame(&mut self, cx: &mut Context) { let frame = self.engine.read(cx).cpu_frame(Monitor::Program, cx); if self.last_cpu_frame.as_ref().is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame)) { self.last_cpu_frame = Some(frame.clone()); + let scope = self.engine.read(cx).scope_data(Monitor::Program, cx); + self.scope_state.update(cx, |state, cx| { + state.luma = (*scope.luma).clone(); + state.chroma = (*scope.chroma).clone(); + cx.notify(); + }); let frame = frame.clone(); self.viewer .update(cx, |viewer, cx| viewer.set_cpu_frame(Some(frame), cx)); } } + + /// One header tab button (picture / scopes), highlighted when active. + fn tab_button( + &self, + id: &'static str, + label: &'static str, + tab: ProgramViewTab, + colors: &gpui::colors::Colors, + cx: &mut Context, + ) -> impl IntoElement { + let active = self.tab == tab; + div() + .id(id) + .px_2() + .py_1() + .rounded_sm() + .border_1() + .border_color(colors.border) + .bg(if active { colors.selected } else { colors.container }) + .text_color(colors.text) + .cursor_pointer() + .child(label) + .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| { + this.tab = tab; + cx.notify(); + })) + } } impl Render for ProgramViewerPanel { @@ -102,6 +193,54 @@ impl Render for ProgramViewerPanel { .map(|sequence| sequence.format) .unwrap_or(crate::oakui::VideoFormat::hd_1080p25()); + let body = match self.tab { + ProgramViewTab::Picture => div() + .flex_1() + .flex() + .child(div().flex_1().child(self.viewer.clone())) + .child( + div() + .w(px(METER_WIDTH)) + .border_l_1() + .border_color(colors.border) + .child(self.meter.clone()), + ), + ProgramViewTab::Scopes => { + let cell = |label: &'static str, scope: AnyElement| { + div() + .flex_1() + .flex() + .flex_col() + .min_w_0() + .child( + div() + .px_2() + .py_1() + .text_xs() + .text_color(colors.disabled) + .child(label), + ) + .child(div().flex_1().min_h_0().child(scope)) + }; + div() + .flex_1() + .flex() + .min_h_0() + .child(cell( + crate::i18n::tr("scope.histogram"), + self.histogram.clone().into_any_element(), + )) + .child(cell( + crate::i18n::tr("scope.waveform"), + self.waveform.clone().into_any_element(), + )) + .child(cell( + crate::i18n::tr("scope.vectorscope"), + self.vectorscope.clone().into_any_element(), + )) + } + }; + div() .size_full() .flex() @@ -120,21 +259,23 @@ impl Render for ProgramViewerPanel { &colors, format_resolution(format.width, format.height), )) - .child(chip(&colors, format_fps(format.rate))), - ) - .child( - div() - .flex_1() - .flex() - .child(div().flex_1().child(self.viewer.clone())) - .child( - div() - .w(px(METER_WIDTH)) - .border_l_1() - .border_color(colors.border) - .child(self.meter.clone()), - ), + .child(chip(&colors, format_fps(format.rate))) + .child(self.tab_button( + "program-tab-picture", + crate::i18n::tr("viewer.picture"), + ProgramViewTab::Picture, + &colors, + cx, + )) + .child(self.tab_button( + "program-tab-scopes", + crate::i18n::tr("viewer.scopes"), + ProgramViewTab::Scopes, + &colors, + cx, + )), ) + .child(body) } } @@ -155,3 +296,56 @@ impl DockPanel for ProgramViewerPanel { .into_any_element() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::oakui::MockEngine; + use gpui::{size, TestAppContext, VisualTestContext}; + + /// The scopes tab renders from the mock engine's synthetic frame without + /// crashing, and the scope state carries that frame's samples. + #[gpui::test] + async fn scopes_tab_renders_from_the_current_frame(cx: &mut TestAppContext) { + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(640.0), px(360.0)), |window, cx| { + let engine = cx.new(|cx| MockEngine::demo(cx)); + let clock = engine.read(cx).program_clock().clone(); + let meter = cx.new(|cx| AudioLevelMeter::new(30, engine.clone(), window, cx)); + ProgramViewerPanel::new(engine, clock, meter, window, cx) + }); + cx.run_until_parked(); + let panel = window.root(cx).expect("program viewer panel root"); + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + + // Draw the picture tab once (fills the scope state from frame 0), + // then switch to the scopes tab and draw it. + cx.update(|window, cx| { + window.draw(cx).clear(); + panel.update(cx, |panel, cx| { + panel.tab = ProgramViewTab::Scopes; + cx.notify(); + }); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(); + }); + + let (luma_len, chroma_len, bins, envelope) = cx.read(|app| { + let panel = panel.read(app); + ( + panel.scope_state.read(app).luma.len(), + panel.scope_state.read(app).chroma.len(), + panel.histogram.read(app).bins(app), + panel.waveform.read(app).envelope(app), + ) + }); + let pixels = (crate::oakui::frames::SYNTH_FRAME_WIDTH + * crate::oakui::frames::SYNTH_FRAME_HEIGHT) as usize; + assert_eq!(luma_len, pixels); + assert_eq!(chroma_len, pixels); + assert_eq!(bins.iter().sum::() as usize, pixels); + assert_eq!(envelope.len(), 128); + } +}