diff --git a/.gitignore b/.gitignore index 47e47db15..d6cea1550 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ ui_*.h *.jsc Makefile* *build-* +!tooling/ffmpeg/build-ffmpeg.sh *.qm *.prl diff --git a/docs/screenshot-window-en.png b/docs/screenshot-window-en.png new file mode 100644 index 000000000..68a085143 Binary files /dev/null and b/docs/screenshot-window-en.png differ diff --git a/docs/screenshot-window.png b/docs/screenshot-window.png index 843d17cf4..8da9555e0 100644 Binary files a/docs/screenshot-window.png and b/docs/screenshot-window.png differ diff --git a/examples/screenshot.rs b/examples/screenshot.rs index c2ab9f503..78db647a0 100644 --- a/examples/screenshot.rs +++ b/examples/screenshot.rs @@ -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 = 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::::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::::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(()) } diff --git a/src/app.rs b/src/app.rs index 84c3ebc35..061efa184 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 PanelRegistry for AppPanelRegistry { )), "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 OakApp { )); 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 OakApp { ); }); - // 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 Render for OakApp { .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); diff --git a/src/i18n.rs b/src/i18n.rs index 77301e291..04929800c 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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", "示波器"), diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index 63b2364c0..cd0439f82 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -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). diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index 2a7f4881e..ce6bde0d7 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -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 { 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.project.path = path; if let Some(name) = self.project.path.file_stem() { diff --git a/src/panels/history.rs b/src/panels/history.rs index 40f7cb0cd..2c9bf4f1a 100644 --- a/src/panels/history.rs +++ b/src/panels/history.rs @@ -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) diff --git a/src/panels/mod.rs b/src/panels/mod.rs index b09438b9a..b65ca5aa4 100644 --- a/src/panels/mod.rs +++ b/src/panels/mod.rs @@ -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, +/// ` · ` 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), + } +} diff --git a/src/panels/program_viewer.rs b/src/panels/program_viewer.rs index d4796baf5..4593ac382 100644 --- a/src/panels/program_viewer.rs +++ b/src/panels/program_viewer.rs @@ -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 Render for ProgramViewerPanel { .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 Render for ProgramViewerPanel { .size_full() .flex() .flex_col() + .overflow_hidden() .child( div() .flex() @@ -261,7 +279,7 @@ impl Render for ProgramViewerPanel { .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 DockPanel for ProgramViewerPanel { 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() } } diff --git a/src/panels/source_viewer.rs b/src/panels/source_viewer.rs index 39526131a..b330513f3 100644 --- a/src/panels/source_viewer.rs +++ b/src/panels/source_viewer.rs @@ -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 Render for SourceViewerPanel { .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 Render for SourceViewerPanel { .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 DockPanel for SourceViewerPanel { 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() } } diff --git a/src/panels/status_bar.rs b/src/panels/status_bar.rs index ad3b543b8..ae7a41452 100644 --- a/src/panels/status_bar.rs +++ b/src/panels/status_bar.rs @@ -94,7 +94,7 @@ impl Render for StatusBar { )) .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(), ))) diff --git a/src/panels/timeline.rs b/src/panels/timeline.rs index 14d0c6603..5892a29e1 100644 --- a/src/panels/timeline.rs +++ b/src/panels/timeline.rs @@ -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 TimelinePanel { } impl Render for TimelinePanel { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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 Render for TimelinePanel { .debug_selector(|| "timeline-canvas".into()) .flex_1() .min_w_0() - .pt(px(view_offset)) .child(self.timeline.clone()), ) .child(right_controls), diff --git a/tests/waveform_e2e.rs b/tests/waveform_e2e.rs new file mode 100644 index 000000000..0418752d7 --- /dev/null +++ b/tests/waveform_e2e.rs @@ -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 . + +//! 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::(), 8); + assert_eq!(std::mem::size_of::(), 8); +} diff --git a/tooling/ffmpeg/build-ffmpeg.sh b/tooling/ffmpeg/build-ffmpeg.sh new file mode 100755 index 000000000..42af7a929 --- /dev/null +++ b/tooling/ffmpeg/build-ffmpeg.sh @@ -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 . + +# 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/, 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-` when pkg-config finds . +COND_LIBS=() +enable_if_pkg() { # + 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 , 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 <