feat(app): UI walkthrough pass — faithful screenshots, dock labels, timeline clips, status bar, meter

- screenshot example inits i18n and captures both zh-CN and en-US
  (docs/screenshot-window{,-en}.png)
- dock tabs size to content (no more 64px truncation); viewer/panel
  titles follow the design (素材查看器·name etc.)
- mock project carries V1/V2 video + A1/A2 audio clips rendered as
  rounded green bars; timeline clip geometry/rounded corners match the
  design; status bar visible with full content; audio meter strip
  docked at the program viewer's right edge; project explorer rows
  have icons
- tests/waveform_e2e.rs: waveform cache extracts real peaks and hits
  cache on re-query (P4 acceptance)
This commit is contained in:
2026-08-13 23:37:57 +08:00
parent c5287ff234
commit f0517fe6af
16 changed files with 506 additions and 94 deletions
+1
View File
@@ -70,6 +70,7 @@ ui_*.h
*.jsc
Makefile*
*build-*
!tooling/ffmpeg/build-ffmpeg.sh
*.qm
*.prl
Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

After

Width:  |  Height:  |  Size: 441 KiB

+89 -36
View File
@@ -17,27 +17,45 @@
//! Offscreen screenshot capture for the app window.
//!
//! Renders the full [`OakApp`] shell at 1600×900 (2× = 3200×1866 px) in an
//! offscreen macOS window and writes the PNG to
//! `app/rust/docs/screenshot-window.png`, using the same
//! [`VisualTestAppContext`] machinery the gpui visual tests use. The window
//! is created at `(-10000, -10000)` so nothing flickers on screen.
//! offscreen macOS window and writes the PNGs to
//! `docs/screenshot-window.png` (zh-CN) and `docs/screenshot-window-en.png`
//! (en-US), using the same [`VisualTestAppContext`] machinery the gpui visual
//! tests use. The window is created at `(-10000, -10000)` so nothing
//! flickers on screen.
//!
//! Unlike the real app's startup ([`oakapp::app::run`]) the example must
//! initialize the i18n layer itself, or every menu renders in the en-US
//! fallback while the panels fall back to their built-in Chinese defaults.
//! The active language is captured first and restored at the end, so the
//! persisted preference is untouched.
//!
//! Run it on the macOS main thread (examples run on the main thread, unlike
//! `#[test]` harness threads):
//!
//! ```text
//! cargo run --example screenshot # 1600×900 → docs/screenshot-window.png
//! cargo run --example screenshot -- 1100 900 # any size (still overwrites the same file)
//! cargo run --example screenshot # 1600×900 → both PNGs
//! cargo run --example screenshot -- 1100 900 # any size (same filenames)
//! ```
use gpui::{px, size, AnyWindowHandle, AppContext, Result, VisualTestAppContext};
use gpui_platform::current_platform;
use oakapp::app::OakApp;
use oakapp::i18n::{self, Language};
use oakapp::oakui::MockEngine;
const DEFAULT_WIDTH: f32 = 1600.0;
const DEFAULT_HEIGHT: f32 = 900.0;
const OUT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window.png");
const OUT_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window.png");
const OUT_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window-en.png");
/// Logical y of the timeline toolbar row, which sits at the top of the
/// bottom dock panel in the default layout: the dock starts at y 27.5 (the
/// menu bar height), the viewers get 60% of the remaining height, and a 6px
/// split handle separates them from the timeline. The toolbar is its 31px
/// first row. The assertion scans a small band around it so minor layout
/// drift does not false-negative.
const TOOLBAR_Y: f32 = 541.0;
const TOOLBAR_BAND: f32 = 44.0;
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
@@ -53,21 +71,59 @@ fn main() -> Result<()> {
let mut cx = VisualTestAppContext::new(current_platform(false));
cx.update(|app| app.init_colors());
let window = cx.open_offscreen_window(size(px(width), px(height)), |window, cx| {
cx.new(|cx| OakApp::<MockEngine>::new(window, None, cx))
})?;
// Initialize the UI language like the real app's startup would: zh-CN
// for the primary screenshot, then en-US for the English one. The
// original persisted language is restored at the end.
let original = i18n::language();
i18n::set_language(Language::ZhCN);
{
let handle = open_shell(&mut cx, width, height);
let image = cx.capture_screenshot(handle)?;
std::fs::create_dir_all(std::path::Path::new(OUT_ZH).parent().unwrap())?;
image.save(OUT_ZH)?;
println!("wrote {OUT_ZH} ({}×{})", image.width(), image.height());
assert_toolbar(&image, "zh-CN");
}
i18n::set_language(Language::EnUs);
{
let handle = open_shell(&mut cx, width, height);
let image = cx.capture_screenshot(handle)?;
std::fs::create_dir_all(std::path::Path::new(OUT_EN).parent().unwrap())?;
image.save(OUT_EN)?;
println!("wrote {OUT_EN} ({}×{})", image.width(), image.height());
assert_toolbar(&image, "en-US");
}
i18n::set_language(original);
Ok(())
}
/// Opens the app shell offscreen and draws enough frames for the layout to
/// settle and the async toolbar-icon assets to decode: the node editor fits
/// its graph once the canvas size is known, the viewers upload their first
/// CPU frame, and the PNG toolbar icons load through the background executor
/// on the frame after the asset future resolves.
fn open_shell(
cx: &mut VisualTestAppContext,
width: f32,
height: f32,
) -> AnyWindowHandle {
let window = cx
.open_offscreen_window(size(px(width), px(height)), |window, cx| {
// Compact pro-app text metrics, matching the real app's startup
// (`src/app.rs run_with` sets rem 14px; gpui's default is 16px).
window.set_rem_size(px(14.0));
cx.new(|cx| OakApp::<MockEngine>::new(window, None, cx))
})
.expect("offscreen window opens");
let handle: AnyWindowHandle = window.into();
// Draw enough frames for the layout to settle and the async toolbar-icon
// assets to decode: the node editor fits its graph once the canvas size
// is known, the viewers upload their first CPU frame, and the PNG
// toolbar icons load through the background executor on the frame after
// the asset future resolves.
for _ in 0..16 {
cx.run_until_parked();
cx.update_window(handle, |_root, window, app| {
let _ = window.draw(app);
})?;
})
.expect("window still open");
}
cx.run_until_parked();
@@ -75,25 +131,27 @@ fn main() -> Result<()> {
cx.run_until_parked();
cx.update_window(handle, |_root, window, app| {
let _ = window.draw(app);
})?;
})
.expect("window still open");
}
cx.run_until_parked();
handle
}
let image = cx.capture_screenshot(handle)?;
// The timeline toolbar's tool icons (16px at 2× = 32px on 48px pitch)
// must render: the toolbar is the 31px row at the top of the bottom dock
// panel. Scan the bottom strip for the 8 tool cells and require most of
// them to contain bright glyph pixels, so a broken icon load fails the
// capture loudly instead of shipping an empty toolbar.
let th = height * 2.0;
/// The timeline toolbar's tool icons (16px at 2× = 32px on 48px pitch) must
/// render: the toolbar is the 31px row at the top of the bottom dock panel.
/// Scan the tool cells for bright glyph pixels, so a broken icon load fails
/// the capture loudly instead of shipping an empty toolbar.
fn assert_toolbar(image: &image::RgbaImage, language: &str) {
// The image is 2× the logical size; convert logical → pixel y. TOOLBAR_Y
// is measured from the window's top edge.
let mut rendered = 0usize;
for (index, cell_x) in [24u32, 88, 152, 216, 280, 344, 408, 472].iter().enumerate() {
for (index, cell_x) in [12u32, 44, 76, 108, 140, 172, 204, 236].iter().enumerate() {
let mut bright = 0u32;
for dy in 0..80i32 {
for dx in 0..32i32 {
for dy in 0..(TOOLBAR_BAND as i32 * 2) {
for dx in 0..40i32 {
let x = (*cell_x as i32 + dx) as u32;
let y = (th as i32 - 320 + dy).max(0) as u32;
let y = ((TOOLBAR_Y as i32 - TOOLBAR_BAND as i32 / 2) * 2 + dy).max(0) as u32;
if x >= image.width() || y >= image.height() {
continue;
}
@@ -103,18 +161,13 @@ fn main() -> Result<()> {
}
}
}
println!("[screenshot] toolbar tool {index} bright pixels: {bright}");
println!("[screenshot] {language} toolbar tool {index} bright pixels: {bright}");
if bright > 20 {
rendered += 1;
}
}
assert!(
rendered >= 6,
"timeline toolbar icons did not render (only {rendered}/8 tool cells had pixels)"
"{language} timeline toolbar icons did not render (only {rendered}/8 tool cells had pixels)"
);
std::fs::create_dir_all(std::path::Path::new(OUT).parent().unwrap())?;
image.save(OUT)?;
println!("wrote {OUT} ({}×{})", image.width(), image.height());
Ok(())
}
+29 -7
View File
@@ -46,7 +46,7 @@ use gpui::{
div, prelude::*, px, size, App, AsyncWindowContext, Bounds, Context, Entity, PathPromptOptions,
Render, Window, WindowBounds, WindowOptions,
};
use gpui_widgets::audio_meter::AudioLevelMeter;
use gpui_widgets::audio_meter::{AudioLevelMeter, MeterOrientation};
use gpui_widgets::dialog::progress::{progress_dialog, ProgressContent};
use gpui_widgets::dialog::{DialogButton, Modal, ModalEvent, ModalOptions};
use gpui_widgets::menu::{Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem};
@@ -204,8 +204,10 @@ impl<E: AppEngine> PanelRegistry for AppPanelRegistry<E> {
)),
"program-viewer" => Some(PanelHandle::new(
cx.new(|cx| {
let meter =
cx.new(|cx| AudioLevelMeter::new(30, self.engine.clone(), window, cx));
let meter = cx.new(|cx| {
AudioLevelMeter::new(30, self.engine.clone(), window, cx)
.with_orientation(MeterOrientation::Vertical)
});
ProgramViewerPanel::new(
self.engine.clone(),
self.program_clock.clone(),
@@ -279,7 +281,10 @@ impl<E: AppEngine> OakApp<E> {
));
timeline.update(cx, |view, _| view.set_clip_decorator(decorator));
}
let meter = cx.new(|cx| AudioLevelMeter::new(3, engine.clone(), window, cx));
let meter = cx.new(|cx| {
AudioLevelMeter::new(3, engine.clone(), window, cx)
.with_orientation(MeterOrientation::Vertical)
});
// --- menu bar ------------------------------------------------------
let menu_bar = cx.new(|cx| MenuBar::new(1, make_menus(true), window, cx));
@@ -375,10 +380,27 @@ impl<E: AppEngine> OakApp<E> {
);
});
// Tune the default split ratios: top 70%, project bin 17% of the row.
// Tune the default split ratios: viewers 60% / timeline 40%, project
// bin 17% of the row. The timeline share leaves room for all four
// tracks (V2/V1 video + A1/A2 audio) plus the ruler and toolbar at
// 1600×900; the viewers keep the remaining ~60%. The program viewer
// (with its audio level strip) is the active tab of its group, so the
// shell opens on the design's visible 素材查看器 | 序列查看器 row rather
// than on the node editor.
let mut layout: DockLayout = dock.read(cx).layout().clone();
layout.resize_split(&NodePath(vec![]), 0.70);
layout.resize_split(&NodePath(vec![]), 0.60);
layout.resize_split(&NodePath(vec![0]), 0.17);
// The program viewer's transport row (six transport buttons, the
// timecode, the 安全框/缩放 toggles) plus its 26px meter strip needs
// ~430px at 1600×900 — more than an equal share of the row gives it,
// and the design makes the program monitor the prominent viewer. Tilt
// the source/program and program/inspector boundaries accordingly so
// the transport's trailing toggles are not clipped.
layout.resize_split_child(&NodePath(vec![0]), 1, 0.52);
layout.resize_split_child(&NodePath(vec![0]), 2, 0.62);
if let Some(path) = layout.find_panel(PROGRAM_VIEWER) {
layout.set_tabs_active(&path, PROGRAM_VIEWER);
}
dock.update(cx, |dock, cx| dock.set_layout(layout, cx));
// --- status bar ----------------------------------------------------
@@ -962,7 +984,7 @@ impl<E: AppEngine> Render for OakApp<E> {
.flex()
.flex_col()
.child(self.menu_bar.clone())
.child(div().flex_1().child(self.dock.clone()))
.child(div().flex_1().min_h_0().child(self.dock.clone()))
.child(self.status_bar.clone());
if let Some(modal) = self.modal.modal_entity() {
root = root.child(modal);
-6
View File
@@ -268,9 +268,6 @@ const EN: &[(&str, &str)] = &[
("history.set_in_point", "Set In Point"),
// --- node editor ---
("node.fit", "Fit"),
// --- 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"),
@@ -418,9 +415,6 @@ const ZH: &[(&str, &str)] = &[
("history.set_in_point", "设置入点"),
// --- node editor ---
("node.fit", "适配"),
// --- viewer header chips ---
("viewer.source", "素材查看器 · 源"),
("viewer.program", "序列查看器 · 节目"),
// --- program viewer tabs and scope labels ---
("viewer.picture", "画面"),
("viewer.scopes", "示波器"),
+7
View File
@@ -117,6 +117,13 @@ pub trait EngineGateway: Sized {
/// The current sequence of the open project, if any.
fn current_sequence(&self) -> Option<&Sequence>;
/// The display name of the source media shown in the source viewer, used
/// in the viewer header and dock tab. Empty when the engine has no source
/// media loaded.
fn source_media_name(&self) -> String {
String::new()
}
/// Open a project file. The backend loads it and becomes the source of
/// truth for [`project`](EngineGateway::project) /
/// [`current_sequence`](EngineGateway::current_sequence).
+21 -9
View File
@@ -445,16 +445,20 @@ impl MockEngine {
label: label.into(),
color,
};
let video = |h: f32| Hsla {
h,
s: 0.55,
l: 0.45,
// Clip colors follow the design's timeline: video and audio clips are
// green bars (#48a26d), slightly lighter for audio. The `h` argument
// is kept so the call sites read like before; every demo clip shares
// the design accent.
let video = |_h: f32| Hsla {
h: 0.402,
s: 0.385,
l: 0.459,
a: 1.0,
};
let audio = |h: f32| Hsla {
h,
s: 0.45,
l: 0.55,
let audio = |_h: f32| Hsla {
h: 0.402,
s: 0.32,
l: 0.54,
a: 1.0,
};
let node_color = |h: f32| Hsla {
@@ -948,7 +952,9 @@ impl MockEngine {
/// The demo audio levels: animated while the program monitor plays.
fn meter_levels(&self) -> Vec<f32> {
if !self.program_playing {
return vec![0.03, 0.03];
// Idle rest level: low but visible, so the program viewer's level
// strip reads as alive (the design shows a lit green strip).
return vec![0.42, 0.35];
}
let t = (self.meter_phase % 120) as f32 / 120.0 * std::f32::consts::TAU;
let level = 0.25 + 0.55 * (t.sin() * 0.6 + (2.0 * t).sin() * 0.4).abs();
@@ -969,6 +975,12 @@ impl EngineGateway for MockEngine {
Some(&self.sequence)
}
fn source_media_name(&self) -> String {
// The demo "source" media shown in the source viewer: the first
// footage item of the mock project.
"第一稿.mp4".into()
}
fn open_project(&mut self, path: PathBuf, cx: &mut Context<Self>) {
self.project.path = path;
if let Some(name) = self.project.path.file_stem() {
+16 -3
View File
@@ -63,13 +63,26 @@ impl Render for HistoryPanel {
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.px_3()
.py_1()
.text_color(colors.text)
.child(div().child(label))
.child(div().text_color(colors.disabled).child(*timestamp)),
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.child(label),
)
.child(
div()
.flex_shrink_0()
.whitespace_nowrap()
.text_color(colors.disabled)
.child(*timestamp),
),
);
}
div().size_full().flex().flex_col().child(list)
+14
View File
@@ -68,3 +68,17 @@ pub(crate) fn chip(colors: &gpui::colors::Colors, label: impl gpui::IntoElement)
.text_color(colors.text)
.child(label)
}
/// The viewer panel title, per the design: `<面板>·<素材/序列名>` in zh-CN,
/// `<Panel> · <name>` in en-US — no redundant "Source"/"Program" placeholder
/// suffix. The panel key is the localized panel name; `name` is the media or
/// sequence name (data, not translated).
pub(crate) fn viewer_title(panel_key: &'static str, name: &str) -> String {
if name.is_empty() {
return crate::i18n::tr(panel_key).to_owned();
}
match crate::i18n::language() {
crate::i18n::Language::ZhCN => format!("{}·{}", crate::i18n::tr(panel_key), name),
crate::i18n::Language::EnUs => format!("{} · {}", crate::i18n::tr(panel_key), name),
}
}
+37 -7
View File
@@ -32,8 +32,8 @@ use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
use crate::oakui::timecode::{format_fps, format_resolution};
use crate::oakui::{AppEngine, Monitor};
use crate::panels::chip;
use crate::panels::ids::PROGRAM_VIEWER;
use crate::panels::{chip, viewer_title};
/// Width of the audio level strip, per the design (26px).
const METER_WIDTH: f32 = 26.0;
@@ -199,15 +199,32 @@ impl<E: AppEngine> Render for ProgramViewerPanel<E> {
.current_sequence()
.map(|sequence| sequence.format)
.unwrap_or(crate::oakui::VideoFormat::hd_1080p25());
let sequence_name = self
.engine
.read(cx)
.current_sequence()
.map(|sequence| sequence.name.clone())
.unwrap_or_default();
let title = viewer_title("panel.program_viewer", &sequence_name);
let body = match self.tab {
ProgramViewTab::Picture => div()
.flex_1()
.flex()
.child(div().flex_1().child(self.viewer.clone()))
.min_h_0()
.min_w_0()
.child(
div()
.flex_1()
.min_w_0()
.min_h_0()
.overflow_hidden()
.child(self.viewer.clone()),
)
.child(
div()
.w(px(METER_WIDTH))
.flex_shrink_0()
.border_l_1()
.border_color(colors.border)
.child(self.meter.clone()),
@@ -252,6 +269,7 @@ impl<E: AppEngine> Render for ProgramViewerPanel<E> {
.size_full()
.flex()
.flex_col()
.overflow_hidden()
.child(
div()
.flex()
@@ -261,7 +279,7 @@ impl<E: AppEngine> Render for ProgramViewerPanel<E> {
.py_1()
.border_b_1()
.border_color(colors.border)
.child(chip(&colors, crate::i18n::tr("viewer.program")))
.child(chip(&colors, title))
.child(chip(
&colors,
format_resolution(format.width, format.height),
@@ -293,13 +311,25 @@ impl<E: AppEngine> DockPanel for ProgramViewerPanel<E> {
PROGRAM_VIEWER
}
fn title(&self, _cx: &App) -> SharedString {
crate::i18n::tr("panel.program_viewer").into()
fn title(&self, cx: &App) -> SharedString {
let name = self
.engine
.read(cx)
.current_sequence()
.map(|sequence| sequence.name.clone())
.unwrap_or_default();
viewer_title("panel.program_viewer", &name).into()
}
fn tab_content(&self, _cx: &App) -> AnyElement {
fn tab_content(&self, cx: &App) -> AnyElement {
let name = self
.engine
.read(cx)
.current_sequence()
.map(|sequence| sequence.name.clone())
.unwrap_or_default();
div()
.child(crate::i18n::tr("panel.program_viewer"))
.child(viewer_title("panel.program_viewer", &name))
.into_any_element()
}
}
+20 -7
View File
@@ -26,7 +26,7 @@ use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
use crate::oakui::timecode::{format_fps, format_resolution};
use crate::oakui::{AppEngine, Monitor};
use crate::panels::chip;
use crate::panels::{chip, viewer_title};
use crate::panels::ids::SOURCE_VIEWER;
/// The source viewer panel.
@@ -94,11 +94,14 @@ impl<E: AppEngine> Render for SourceViewerPanel<E> {
.current_sequence()
.map(|sequence| sequence.format)
.unwrap_or(crate::oakui::VideoFormat::hd_1080p25());
let media = self.engine.read(cx).source_media_name();
let title = viewer_title("panel.source_viewer", &media);
div()
.size_full()
.flex()
.flex_col()
.overflow_hidden()
.child(
div()
.flex()
@@ -108,14 +111,21 @@ impl<E: AppEngine> Render for SourceViewerPanel<E> {
.py_1()
.border_b_1()
.border_color(colors.border)
.child(chip(&colors, crate::i18n::tr("viewer.source")))
.child(chip(&colors, title))
.child(chip(
&colors,
format_resolution(format.width, format.height),
))
.child(chip(&colors, format_fps(format.rate))),
)
.child(div().flex_1().child(self.viewer.clone()))
.child(
div()
.flex_1()
.min_w_0()
.min_h_0()
.overflow_hidden()
.child(self.viewer.clone()),
)
}
}
@@ -126,13 +136,16 @@ impl<E: AppEngine> DockPanel for SourceViewerPanel<E> {
SOURCE_VIEWER
}
fn title(&self, _cx: &App) -> SharedString {
crate::i18n::tr("panel.source_viewer").into()
fn title(&self, cx: &App) -> SharedString {
viewer_title("panel.source_viewer", &self.engine.read(cx).source_media_name()).into()
}
fn tab_content(&self, _cx: &App) -> AnyElement {
fn tab_content(&self, cx: &App) -> AnyElement {
div()
.child(crate::i18n::tr("panel.source_viewer"))
.child(viewer_title(
"panel.source_viewer",
&self.engine.read(cx).source_media_name(),
))
.into_any_element()
}
}
+1 -1
View File
@@ -94,7 +94,7 @@ impl<E: AppEngine> Render for StatusBar<E> {
))
.child(div().px_2().text_color(colors.disabled).child(project))
.child(div().px_2().text_color(colors.disabled).child(format!(
"{} · {}",
"{} {}",
crate::i18n::tr("status.backend"),
self.engine.read(cx).backend_name(),
)))
+2 -18
View File
@@ -37,9 +37,7 @@
//! narrow widths the sliders squeezed into the ruler's right side). They
//! now live in a fixed-width trailing slot beside the timeline body, and the
//! timeline wrapper is `min_w_0` so the ruler always keeps the remaining
//! space — no overlap at 1600×900 or down to ~1100px wide. On hidpi (2x)
//! displays the render compensates for a gpui view-positioning quirk with a
//! top padding on the timeline canvas (see the note in [`Render::render`]).
//! space — no overlap at 1600×900 or down to ~1100px wide.
use gpui::colors::DefaultColors;
use gpui::dock::{DockPanel, PanelEvent};
@@ -161,22 +159,9 @@ impl<E: AppEngine> TimelinePanel<E> {
}
impl<E: AppEngine> Render for TimelinePanel<E> {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
// The gpui view-positioning code prepaints a view's root at the panel
// content origin rather than at its flex wrapper's position, which is
// only noticeable on hidpi (2x) displays where the fixed 31px toolbar
// row and the timeline view's own 32px ruler row would otherwise
// overlap. The canvas wrapper carries a compensating top padding on
// hidpi so the ruler lands just below the toolbar; at 1x the layout
// is already correct and no padding is applied.
let view_offset = if window.scale_factor() > 1.5 {
TOOLBAR_HEIGHT + 2.0
} else {
0.0
};
// --- toolbar row (fixed 31px, above the ruler) --------------------
let mut toolbar = div()
.debug_selector(|| "timeline-toolbar".into())
@@ -345,7 +330,6 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
.debug_selector(|| "timeline-canvas".into())
.flex_1()
.min_w_0()
.pt(px(view_offset))
.child(self.timeline.clone()),
)
.child(right_controls),
+60
View File
@@ -0,0 +1,60 @@
// 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/>.
//! M12 P4 acceptance: the waveform cache extracts real peaks for a media
//! file with an audio track, and hits the cache on re-query.
//!
//! Runs in its own test binary: the FFmpeg teardown state after a video
//! decode + an audio decode in one process crashes at exit, so the
//! waveform test stays isolated from the in-lib media tests.
use oakapp::oakui::ffi::{
oakaudio_waveform_extract, oakengine_testmedia_write_clip, oakapp_minmax,
};
use oakapp::oakui::waveform::{WaveformCache, MinMax};
#[test]
fn waveform_extract_and_cache_hit() {
let media = std::env::temp_dir().join(format!("oakapp_waveform_{}.mp4", std::process::id()));
let cpath = std::ffi::CString::new(media.to_string_lossy().into_owned()).unwrap();
assert_eq!(
unsafe { oakengine_testmedia_write_clip(cpath.as_ptr(), 64, 64, 10, 10) },
0
);
let filename = media.to_string_lossy().into_owned();
let cache = WaveformCache::new(25.0);
cache.refresh(7, &filename, 250);
let wf = cache.get(7).expect("waveform extracted");
assert!(wf.channel_count >= 1);
assert!(!wf.peaks.is_empty(), "the sine tone yields peaks");
let peak = wf
.peaks
.iter()
.fold(0.0f32, |a, p| a.max(p.max.abs().max(p.min.abs())));
assert!(peak > 0.1, "the sine tone is audible in the peaks: {peak}");
// Cache hit: a second refresh does not re-extract.
cache.refresh(7, &filename, 250);
let again = cache.get(7).unwrap();
assert_eq!(again.peaks.len(), wf.peaks.len());
let _ = std::fs::remove_file(&media);
}
/// The MinMax mirror must stay layout-compatible with the oakaudio C ABI.
#[test]
fn minmax_layout_is_two_f32s() {
assert_eq!(std::mem::size_of::<MinMax>(), 8);
assert_eq!(std::mem::size_of::<oakapp_minmax>(), 8);
}
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env bash
# 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/>.
# Builds a project-owned FFmpeg for the oakcodec/oakaudio `ffmpeg-next`
# dependency and installs it into .cache/ffmpeg. Point cargo at it with:
#
# export FFMPEG_DIR="$(pwd)/.cache/ffmpeg"
#
# Why a script instead of ffmpeg-next's `build` cargo feature: the
# feature clones release/<crate-version>, and every such pairing is
# broken upstream (9.0.0 -> FFmpeg 9.0 headers removed AVCodec fields;
# 8.1.0 -> FFmpeg 8.1 added enum variants; 8.0.0 -> FFmpeg 8.0 renamed
# FF_PROFILE_* to AV_PROFILE_*). The one known-good pairing is
# ffmpeg-next 9.0.0 against FFmpeg 8.x headers, so this script builds
# release/8.0.
#
# Oak is GPL, so the GPL-licensed parts of FFmpeg and every free-license
# external codec library are enabled. Hardware acceleration is enabled
# per host OS, and every optional piece (external libraries, VAAPI/VDPAU,
# ffnvcodec, ...) is probed first — anything the machine does not provide
# is silently left out, so the script works on a bare macOS/Linux box.
# Install the external libraries with tooling/install-deps.sh first.
#
# Usage: tooling/ffmpeg/build-ffmpeg.sh [-j N] [--shared]
# -j N parallel make jobs (default: nproc/sysctl)
# --shared build shared libraries instead of the default static+PIC
set -euo pipefail
FFMPEG_VERSION="8.1"
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
PREFIX="$ROOT/.cache/ffmpeg"
SRC="$ROOT/.cache/ffmpeg-src"
JOBS="$( (nproc 2>/dev/null) || sysctl -n hw.ncpu)"
SHARED=0
while [ $# -gt 0 ]; do
case "$1" in
-j) JOBS="$2"; shift 2 ;;
--shared) SHARED=1; shift ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
# --- Helpers ---------------------------------------------------------------
have_pkg() { pkg-config --exists "$1" 2>/dev/null; }
# Adds `--enable-<flag>` when pkg-config finds <package>.
COND_LIBS=()
enable_if_pkg() { # <pkg-config name> <configure flag>
if have_pkg "$1"; then
COND_LIBS+=("--enable-$2")
echo " + $2 (found $1)"
else
echo " - $2 (no $1, skipped)"
fi
}
OS="$(uname -s)"
# Homebrew keeps everything under its own prefix, off the compiler's
# default search paths; several .pc files (lame, snappy, theora's ogg
# link line) are not self-sufficient, so add the prefix globally.
if [ "$OS" = Darwin ]; then
BREW_PREFIX="$(brew --prefix 2>/dev/null || echo /opt/homebrew)"
FLAGS_EXTRA=("--extra-cflags=-I$BREW_PREFIX/include" "--extra-ldflags=-L$BREW_PREFIX/lib")
else
FLAGS_EXTRA=()
fi
# --- Source ----------------------------------------------------------------
mkdir -p "$ROOT/.cache"
if [ ! -d "$SRC" ]; then
echo ">> cloning FFmpeg release/$FFMPEG_VERSION"
git clone --depth=1 -b "release/$FFMPEG_VERSION" \
https://github.com/FFmpeg/FFmpeg "$SRC"
fi
# --- Configure flags --------------------------------------------------------
FLAGS=(
"--prefix=$PREFIX"
--enable-gpl
--enable-version3
--disable-doc
--disable-debug
--disable-programs
--enable-avcodec --enable-avformat --enable-avfilter
--enable-avutil --enable-swscale --enable-swresample
)
if [ "$SHARED" = 1 ]; then
FLAGS+=(--enable-shared --disable-static)
else
FLAGS+=(--enable-static --disable-shared --enable-pic)
fi
FLAGS+=("${FLAGS_EXTRA[@]}")
echo ">> external codec/filter libraries (enabled when found):"
# Free-license external encoders/decoders. GPL-compatible only; the
# non-free ones (fdk-aac, OpenSSL in some jurisdictions, ...) stay off.
enable_if_pkg x264 libx264
enable_if_pkg x265 libx265
enable_if_pkg svt-av1 libsvtav1
enable_if_pkg dav1d libdav1d
enable_if_pkg vpx libvpx
enable_if_pkg opus libopus
enable_if_pkg vorbis libvorbis
enable_if_pkg theora libtheora
enable_if_pkg lame libmp3lame
# Homebrew's lame.pc points its include dir at include/lame while FFmpeg
# includes <lame/lame.h>, and its library dir is off the default search
# path; pass both explicitly.
if have_pkg lame; then
FLAGS+=("--extra-cflags=-I$(pkg-config --variable=includedir lame)")
FLAGS+=("--extra-ldflags=-L$(pkg-config --variable=libdir lame)")
fi
enable_if_pkg twolame libtwolame
enable_if_pkg speex libspeex
# Homebrew's openjpeg installs libopenjp2.pc outside the default
# pkg-config search path.
if [ -d /opt/homebrew/lib/pkgconfig/openjpeg ]; then
export PKG_CONFIG_PATH="${PKG_CONFIG_PATH:-}:/opt/homebrew/lib/pkgconfig/openjpeg"
fi
enable_if_pkg libopenjp2 libopenjpeg
enable_if_pkg openh264 libopenh264
enable_if_pkg snappy libsnappy
enable_if_pkg wavpack libwavpack
enable_if_pkg webp libwebp
enable_if_pkg xvid libxvid
enable_if_pkg kvazaar libkvazaar
enable_if_pkg shine libshine
enable_if_pkg gsm libgsm
enable_if_pkg opencore-amrnb libopencore-amrnb
enable_if_pkg opencore-amrwb libopencore-amrwb
enable_if_pkg ilbc libilbc
# Subtitles / text rendering (free).
enable_if_pkg freetype2 libfreetype
enable_if_pkg fribidi libfribidi
enable_if_pkg fontconfig libfontconfig
enable_if_pkg libass libass
# TLS for network protocols (GPL-compatible).
if have_pkg gnutls; then
COND_LIBS+=("--enable-gnutls")
echo " + gnutls"
else
echo " - gnutls (skipped)"
fi
FLAGS+=("${COND_LIBS[@]}")
echo ">> hardware acceleration:"
case "$OS" in
Darwin)
# VideoToolbox/AudioToolbox ship with the OS SDK — always on.
FLAGS+=(--enable-videotoolbox --enable-audiotoolbox)
echo " + videotoolbox, audiotoolbox"
;;
Linux)
if have_pkg libva; then FLAGS+=(--enable-vaapi); echo " + vaapi"; else echo " - vaapi (no libva)"; fi
if have_pkg vdpau; then FLAGS+=(--enable-vdpau); echo " + vdpau"; else echo " - vdpau (no vdpau)"; fi
if have_pkg libdrm; then FLAGS+=(--enable-libdrm); echo " + libdrm"; else echo " - libdrm"; fi
;;
MINGW*|MSYS*|CYGWIN*)
FLAGS+=(--enable-d3d11va --enable-dxva2 --enable-mediafoundation)
echo " + d3d11va, dxva2, mediafoundation"
;;
esac
# NVIDIA (ffnvcodec headers are distribution-free; enable when present).
if [ -d /usr/local/cuda ] || pkg-config --exists ffnvcodec 2>/dev/null; then
FLAGS+=(--enable-nvenc --enable-cuda-llvm)
echo " + nvenc/cuda"
else
echo " - nvenc/cuda (no ffnvcodec headers)"
fi
# --- Build ------------------------------------------------------------------
echo ">> configure"
cd "$SRC"
./configure "${FLAGS[@]}"
echo ">> make -j$JOBS"
make -j"$JOBS"
make install
cat <<EOF
Done. To build Oak against this FFmpeg:
export FFMPEG_DIR="$PREFIX"
cargo build
(Unset FFMPEG_DIR to go back to the system pkg-config FFmpeg.)
EOF