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
This commit is contained in:
2026-08-11 17:04:42 +08:00
parent f908724a7a
commit 7b295b7661
8 changed files with 473 additions and 54 deletions
+12
View File
@@ -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", "上一帧"),
+8
View File
@@ -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<RenderImage>;
/// 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<Self>);
+10 -8
View File
@@ -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<f32>) {
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
+29 -7
View File
@@ -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<NodeId>,
/// 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<HashMap<Monitor, (i64, Arc<RenderImage>)>>,
/// 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<HashMap<Monitor, (i64, Arc<RenderImage>, 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>) {
self.add_track(kind, cx);
}
@@ -1408,15 +1414,31 @@ impl MockEngine {
pub fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc<RenderImage> {
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)]
+2 -1
View File
@@ -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};
+43 -19
View File
@@ -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<HashMap<Monitor, (i64, Arc<RenderImage>)>>,
cpu_frame_cache: Mutex<HashMap<Monitor, (i64, Arc<RenderImage>, 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<RenderImage> {
/// 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<RenderImage> {
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<Self>) {
let Some(seq) = self.seq_ptr() else {
return;
+156
View File
@@ -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 <http://www.gnu.org/licenses/>.
//! 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<Vec<f32>>,
/// Per-pixel chroma `(Cb, Cr)` in `0..=1`, centered on `0.5`.
pub chroma: Arc<Vec<(f32, f32)>>,
}
/// 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));
}
}
+213 -19
View File
@@ -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<f32>,
/// Per-pixel chroma `(Cb, Cr)` of the current frame (`0..=1`).
chroma: Vec<(f32, f32)>,
}
impl LumaDataSource for ScopeState {
fn luma_samples(&self) -> Vec<f32> {
self.luma.clone()
}
}
impl ChromaDataSource for ScopeState {
fn chroma_samples(&self) -> Vec<(f32, f32)> {
self.chroma.clone()
}
}
/// The program viewer panel.
pub struct ProgramViewerPanel<E: AppEngine> {
viewer: Entity<ViewerWidget<E::Clock>>,
@@ -43,6 +77,16 @@ pub struct ProgramViewerPanel<E: AppEngine> {
/// 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<std::sync::Arc<gpui::RenderImage>>,
/// The active body tab.
tab: ProgramViewTab,
/// The scope samples backing the three scope widgets.
scope_state: Entity<ScopeState>,
/// The histogram scope.
histogram: Entity<Histogram<ScopeState>>,
/// The waveform scope.
waveform: Entity<Waveform<ScopeState>>,
/// The vectorscope.
vectorscope: Entity<Vectorscope<ScopeState>>,
}
impl<E: AppEngine> ProgramViewerPanel<E> {
@@ -68,26 +112,73 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
})
.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<Self>) {
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<Self>,
) -> 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<E: AppEngine> Render for ProgramViewerPanel<E> {
@@ -102,6 +193,54 @@ impl<E: AppEngine> Render for ProgramViewerPanel<E> {
.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<E: AppEngine> Render for ProgramViewerPanel<E> {
&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<E: AppEngine> DockPanel for ProgramViewerPanel<E> {
.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::<u32>() as usize, pixels);
assert_eq!(envelope.len(), 128);
}
}