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:
+12
@@ -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", "上一帧"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user