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:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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", "示波器"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Executable
+209
@@ -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
|
||||
Reference in New Issue
Block a user