feat(app): track-header toggles, effect library, panel titles (design parity)

- track headers: name + visible/mute/lock toggles, undoable through
  graphops, honored by the montage (muted tracks are skipped) and by
  the edit guards (locked tracks reject trims/moves/deletes)
- project explorer gains a panel title row; the tree/icons toggle is
  now small icon buttons with tooltips
- new effect library dock panel (every addable effect; double-click
  appends to the selected clip's chain)
- screenshots refreshed (zh/en, window/manager/preferences)
This commit is contained in:
2026-08-17 01:37:31 +08:00
parent c5f1d0d76c
commit 21cc2fba24
15 changed files with 749 additions and 9 deletions
+164
View File
@@ -1012,6 +1012,98 @@ pub fn track_height(p: &ProjectRef, track: NodeId) -> Option<f64> {
track_behavior(&g.graph, track).map(|t| t.height)
}
/// A track's muted flag (`None` when the id is stale). Muted means
/// "silenced" on audio tracks and "hidden" on video/subtitle tracks
/// (Olive parity: one flag drives both).
pub fn track_muted(p: &ProjectRef, track: NodeId) -> Option<bool> {
let g = lock(p);
track_behavior(&g.graph, track).map(|t| t.muted)
}
/// A track's locked flag (`None` when the id is stale).
pub fn track_locked(p: &ProjectRef, track: NodeId) -> Option<bool> {
let g = lock(p);
track_behavior(&g.graph, track).map(|t| t.locked)
}
/// An undoable track-flag set (the closures capture the previous value, so
/// the undo restores it exactly). Shared by the mute/hide and lock toggles.
fn set_track_flag(
p: &ProjectRef,
track: NodeId,
field: TrackFlag,
value: bool,
name: &str,
) -> Result<(), String> {
let old = {
let g = lock(p);
let t = track_behavior(&g.graph, track)
.ok_or_else(|| "set track flag: the node is not a track".to_string())?;
match field {
TrackFlag::Muted => t.muted,
TrackFlag::Locked => t.locked,
}
};
if old == value {
return Ok(());
}
let (p1, p2) = (p.clone(), p.clone());
push(
oakundo::undocommand::UndoCommand::from_closures(
move || {
let mut g = lock(&p1);
if let Some(t) = g
.graph
.get_mut(track)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<TrackBehavior>())
{
match field {
TrackFlag::Muted => t.muted = value,
TrackFlag::Locked => t.locked = value,
}
}
},
move || {
let mut g = lock(&p2);
if let Some(t) = g
.graph
.get_mut(track)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<TrackBehavior>())
{
match field {
TrackFlag::Muted => t.muted = old,
TrackFlag::Locked => t.locked = old,
}
}
},
),
name,
)
}
/// The track flags the undoable setters cover.
#[derive(Clone, Copy)]
enum TrackFlag {
/// Muted (audio) / hidden (video, subtitle).
Muted,
/// Locked against clip edits.
Locked,
}
/// Set a track's muted flag (undoable "Set Track Muted"). On video and
/// subtitle tracks this is the visibility (show/hide) toggle.
pub fn set_track_muted(p: &ProjectRef, track: NodeId, muted: bool) -> Result<(), String> {
set_track_flag(p, track, TrackFlag::Muted, muted, "Set Track Muted")
}
/// Set a track's locked flag (undoable "Set Track Locked"). Locked tracks
/// reject clip edits (the app layer refuses trim/move/split/delete).
pub fn set_track_locked(p: &ProjectRef, track: NodeId, locked: bool) -> Result<(), String> {
set_track_flag(p, track, TrackFlag::Locked, locked, "Set Track Locked")
}
/// Set a track's height in internal units (NOT undoable, mirroring the
/// facade's `oakengine_track_set_height`).
pub fn set_track_height(p: &ProjectRef, track: NodeId, height: f64) {
@@ -1454,3 +1546,75 @@ pub fn test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// A project with one sequence holding one track of `kind`; returns the
/// project, the sequence and the track's node id.
fn project_with_track(kind: TrackType) -> (ProjectRef, NodeId, NodeId) {
let project = create_project();
let seq = create_sequence(&project, "Seq");
let index = add_track(&project, seq, kind).expect("add a track");
let track = {
let g = lock(&project);
track_ids(&g.graph, seq, kind)[index]
};
(project, seq, track)
}
/// The track flag setters flip the flag as ONE undoable entry each;
/// undo restores the previous value and redo re-applies.
#[test]
fn track_flag_setters_toggle_and_undo() {
let _g = test_lock();
oakundo::global::clear().unwrap();
let (project, _seq, track) = project_with_track(TrackType::Video);
assert_eq!(track_muted(&project, track), Some(false));
assert_eq!(track_locked(&project, track), Some(false));
set_track_muted(&project, track, true).expect("mute the track");
assert_eq!(track_muted(&project, track), Some(true));
set_track_locked(&project, track, true).expect("lock the track");
assert_eq!(track_locked(&project, track), Some(true));
oakundo::global::undo().unwrap();
assert_eq!(track_locked(&project, track), Some(false));
oakundo::global::undo().unwrap();
assert_eq!(track_muted(&project, track), Some(false));
oakundo::global::redo().unwrap();
assert_eq!(track_muted(&project, track), Some(true));
oakundo::global::clear().unwrap();
}
/// Setting a flag to its current value pushes no undo row.
#[test]
fn track_flag_setter_noop_when_unchanged() {
let _g = test_lock();
oakundo::global::clear().unwrap();
let (project, _seq, track) = project_with_track(TrackType::Audio);
let before = oakundo::global::count().unwrap();
set_track_muted(&project, track, false).expect("mute already false");
set_track_locked(&project, track, false).expect("lock already false");
assert_eq!(oakundo::global::count().unwrap(), before);
oakundo::global::clear().unwrap();
}
/// A stale (non-track) id is rejected, not silently ignored.
#[test]
fn track_flag_setters_reject_non_tracks() {
let _g = test_lock();
oakundo::global::clear().unwrap();
let (project, seq, _track) = project_with_track(TrackType::Video);
assert!(set_track_muted(&project, seq, true).is_err());
assert!(set_track_locked(&project, seq, true).is_err());
oakundo::global::clear().unwrap();
}
}
+196 -1
View File
@@ -1205,6 +1205,41 @@ impl AppEngine for MockEngine {
self.apply_effect_event(event, cx);
}
fn addable_effects(&self) -> Vec<(String, String)> {
// The demo list is the real factory's video-effect table, so the
// effect library shows the same entries the real engine would.
crate::oakui::effectchain::addable_effects()
}
fn add_effect(
&mut self,
index: usize,
type_id: &str,
cx: &mut Context<Self>,
) -> Result<(), String> {
let Some((_, name)) = crate::oakui::effectchain::addable_effects()
.into_iter()
.find(|(id, _)| id == type_id)
else {
return Err(format!("unknown effect \"{type_id}\""));
};
let id = EffectId(self.next_effect_id);
self.next_effect_id += 1;
let card = MockEffect {
id,
kind: EffectCardKind::Effect,
title: name.into(),
subtitle: None,
enabled: true,
expanded: false,
badge: None,
};
let index = index.min(self.effects.len());
self.effects.insert(index, card);
cx.notify();
Ok(())
}
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>) {
self.apply_node_graph_event(event, cx);
}
@@ -1223,6 +1258,9 @@ impl AppEngine for MockEngine {
new_frame,
} => {
if let Some((track, index)) = self.mock_clip_position(*clip) {
if self.tracks[track].locked {
return;
}
let clip = &mut self.tracks[track].clips[index];
match edge {
gpui::timeline::TrimEdge::Start => {
@@ -1245,6 +1283,11 @@ impl AppEngine for MockEngine {
let Some((track, index)) = self.mock_clip_position(*clip) else {
return;
};
if self.tracks[track].locked
|| self.tracks.get(*new_track).map(|t| t.locked).unwrap_or(true)
{
return;
}
let mut clip = self.tracks[track].clips.remove(index);
let length = clip.range.end.0 - clip.range.start.0;
clip.range = FrameRange::new(*new_start, Frame(new_start.0 + length));
@@ -1263,6 +1306,30 @@ impl AppEngine for MockEngine {
| TimelineEvent::TrackSelected { .. }
| TimelineEvent::TransitionChanged { .. }
| TimelineEvent::ZoomChanged(_) => {}
TimelineEvent::TrackToggleRequested { track, toggle } => {
// The demo model applies the toggles directly (no undo in
// mock mode); the muted flag doubles as video visibility,
// mirroring the real engine.
let Some(track) = self.tracks.get_mut(*track) else {
return;
};
match toggle {
gpui::timeline::TrackHeaderEvent::ToggleLock => {
track.locked = !track.locked;
}
gpui::timeline::TrackHeaderEvent::ToggleMute => {
track.muted = !track.muted;
}
gpui::timeline::TrackHeaderEvent::ToggleSolo => {
track.solo = !track.solo;
}
gpui::timeline::TrackHeaderEvent::ToggleVisibility => {
track.visible = !track.visible;
track.muted = !track.visible;
}
}
cx.notify();
}
TimelineEvent::WorkAreaPreview { start, end } => {
self.set_workarea_preview(*start, *end, cx);
}
@@ -1281,6 +1348,9 @@ impl AppEngine for MockEngine {
let Some((track, index)) = self.mock_clip_position(clip) else {
return;
};
if self.tracks[track].locked {
return;
}
self.split_mock_clip(track, index, time);
cx.notify();
}
@@ -1291,6 +1361,7 @@ impl AppEngine for MockEngine {
.tracks
.iter()
.enumerate()
.filter(|(_, track)| !track.locked)
.flat_map(|(track_index, track)| {
track
.clips
@@ -1311,6 +1382,9 @@ impl AppEngine for MockEngine {
let Some((track, index)) = self.mock_clip_position(clip) else {
return;
};
if self.tracks[track].locked {
return;
}
let removed = self.tracks[track].clips.remove(index);
if ripple {
// Shift the following clips on the same track left by the removed
@@ -1994,10 +2068,131 @@ mod tests {
}
#[gpui::test]
async fn mock_split_at_playhead_and_ripple_delete(cx: &mut TestAppContext) {
async fn track_toggle_requests_flip_the_track_flags(cx: &mut TestAppContext) {
use gpui::timeline::TrackHeaderEvent;
cx.update(|app| {
let engine = demo_engine(app);
// V1 (display index 1) starts unlocked + visible.
let v1 = engine.read(app).track(1).expect("V1");
assert!(!v1.is_locked());
assert!(v1.is_visible());
engine.update(app, |engine, cx| {
engine.apply_timeline_event(
&TimelineEvent::TrackToggleRequested {
track: 1,
toggle: TrackHeaderEvent::ToggleLock,
},
cx,
);
});
assert!(engine.read(app).track(1).expect("V1").is_locked());
engine.update(app, |engine, cx| {
engine.apply_timeline_event(
&TimelineEvent::TrackToggleRequested {
track: 1,
toggle: TrackHeaderEvent::ToggleVisibility,
},
cx,
);
});
let v1 = engine.read(app).track(1).expect("V1");
assert!(!v1.is_visible(), "the visibility toggle hides the track");
// A1 (display index 2) mutes.
engine.update(app, |engine, cx| {
engine.apply_timeline_event(
&TimelineEvent::TrackToggleRequested {
track: 2,
toggle: TrackHeaderEvent::ToggleMute,
},
cx,
);
});
assert!(engine.read(app).track(2).expect("A1").is_muted());
});
}
#[gpui::test]
async fn locked_tracks_reject_clip_edits(cx: &mut TestAppContext) {
use gpui::timeline::TrackHeaderEvent;
cx.update(|app| {
let engine = demo_engine(app);
// Lock V1 (the B-roll track, display index 1).
engine.update(app, |engine, cx| {
engine.apply_timeline_event(
&TimelineEvent::TrackToggleRequested {
track: 1,
toggle: TrackHeaderEvent::ToggleLock,
},
cx,
);
});
// Trim, move and delete are all refused.
engine.update(app, |engine, cx| {
engine.apply_timeline_event(
&TimelineEvent::ClipTrimRequested {
clip: ClipId(12),
edge: TrimEdge::Start,
new_frame: Frame(280),
},
cx,
);
engine.apply_timeline_event(
&TimelineEvent::ClipMoveRequested {
clip: ClipId(12),
new_track: 0,
new_start: Frame(300),
},
cx,
);
engine.delete_clip(ClipId(12), false, cx);
});
let v1 = engine.read(app).track(1).expect("V1");
let b_roll = v1
.clips()
.iter()
.find(|c| c.id() == ClipId(12))
.expect("B-roll survives the rejected delete");
assert_eq!(
b_roll.range(),
FrameRange::new(Frame(240), Frame(600)),
"trim and move were rejected"
);
});
}
#[gpui::test]
async fn add_effect_appends_a_named_card(cx: &mut TestAppContext) {
cx.update(|app| {
let engine = demo_engine(app);
let effects = engine.read(app).addable_effects();
assert!(!effects.is_empty(), "the demo list is the factory table");
let (type_id, name) = effects[0].clone();
let before = engine.read(app).effects().len();
engine.update(app, |engine, cx| {
engine
.add_effect(usize::MAX, &type_id, cx)
.expect("add the effect");
});
let stack = engine.read(app).effects();
assert_eq!(stack.len(), before + 1);
assert_eq!(
stack.last().map(|e| e.title().to_string()).as_deref(),
Some(name.as_str()),
"the card was appended at the chain end"
);
});
}
#[gpui::test]
async fn mock_split_at_playhead_and_ripple_delete(cx: &mut TestAppContext) { cx.update(|app| {
let engine = demo_engine(app);
// Park the program playhead inside 开场 (0–240) and split there.
engine.update(app, |engine, cx| {
engine.request_frame(Monitor::Program, Frame(120), cx);
+105 -4
View File
@@ -71,7 +71,7 @@ use gpui::effect_stack::{
use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent};
use gpui::timeline::{
ClipData, ClipId, Frame, FrameRange, FrameRate, Marker, TimelineDataSource, TimelineEvent,
TrackData, TrackKind, TrimEdge,
TrackData, TrackHeaderEvent, TrackKind, TrimEdge,
};
use gpui::{prelude::*, px, App, Context, Entity, Hsla, Pixels, RenderImage, SharedString};
use gpui_widgets::audio_meter::AudioMeterDataSource;
@@ -1134,14 +1134,19 @@ impl RealEngine {
})
})
.collect();
// The muted flag doubles as the video/subtitle visibility toggle
// (Olive parity: the eye button flips `muted`).
let (muted, locked) = graphops::track_behavior(graph, track)
.map(|t| (t.muted, t.locked))
.unwrap_or((false, false));
RealTrack {
kind: track_kind_of(kind),
name: name.into(),
height,
locked: false,
muted: false,
locked,
muted,
solo: false,
visible: true,
visible: !muted,
clips,
track,
track_index,
@@ -1197,6 +1202,19 @@ impl RealEngine {
.then_some(block)
}
/// Whether the track hosting clip `block` is locked (locked tracks
/// reject every clip edit: trim, move, split, delete).
fn clip_track_locked(&self, block: NodeId) -> bool {
let Some(project) = self.project_ref() else {
return false;
};
let guard = graphops::lock(project);
graphops::clip_track(&guard.graph, block)
.and_then(|track| graphops::track_behavior(&guard.graph, track))
.map(|t| t.locked)
.unwrap_or(false)
}
/// The selected clip's block node, or `None` when no single clip is
/// selected.
fn selected_clip_node(&self) -> Option<NodeId> {
@@ -1750,6 +1768,9 @@ impl AppEngine for RealEngine {
let Some(block) = self.clip_block(*clip) else {
return;
};
if self.clip_track_locked(block) {
return;
}
let Some(project) = self.project.clone() else {
return;
};
@@ -1786,6 +1807,9 @@ impl AppEngine for RealEngine {
let Some(block) = self.clip_block(*clip) else {
return;
};
if self.clip_track_locked(block) {
return;
}
let Some(project) = self.project.clone() else {
return;
};
@@ -1818,6 +1842,30 @@ impl AppEngine for RealEngine {
| TimelineEvent::TrackSelected { .. }
| TimelineEvent::TransitionChanged { .. }
| TimelineEvent::ZoomChanged(_) => {}
TimelineEvent::TrackToggleRequested { track, toggle } => {
// The header toggles map onto the undoable track flag
// setters. The muted flag doubles as the video/subtitle
// visibility toggle (Olive parity); the model has no solo
// flag yet, so solo requests are inert.
let (Some(t), Some(project)) =
(self.tracks.get(*track), self.project.clone())
else {
return;
};
let result = match toggle {
TrackHeaderEvent::ToggleLock => {
graphops::set_track_locked(&project, t.track, !t.locked)
}
TrackHeaderEvent::ToggleMute => {
graphops::set_track_muted(&project, t.track, !t.muted)
}
TrackHeaderEvent::ToggleVisibility => {
graphops::set_track_muted(&project, t.track, t.visible)
}
TrackHeaderEvent::ToggleSolo => Ok(()),
};
self.apply_edit(result, "toggle track flag", cx);
}
TimelineEvent::WorkAreaPreview { start, end } => {
self.set_workarea_preview(*start, *end, cx);
}
@@ -1836,6 +1884,9 @@ impl AppEngine for RealEngine {
let (Some(block), Some(project)) = (self.clip_block(clip), self.project.clone()) else {
return;
};
if self.clip_track_locked(block) {
return;
}
let result = graphops::split_clip(&project, block, time.0);
self.apply_edit(result, "split clip", cx);
}
@@ -1848,6 +1899,7 @@ impl AppEngine for RealEngine {
let targets: Vec<NodeId> = self
.tracks
.iter()
.filter(|track| !track.locked)
.flat_map(|track| {
track.clips.iter().filter_map(|clip| {
if clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0 {
@@ -1970,6 +2022,9 @@ impl AppEngine for RealEngine {
let (Some(block), Some(project)) = (self.clip_block(clip), self.project.clone()) else {
return;
};
if self.clip_track_locked(block) {
return;
}
let result = if ripple {
graphops::ripple_delete_clip(&project, block)
} else {
@@ -3028,6 +3083,52 @@ mod tests {
let _ = std::fs::remove_file(&media);
}
/// Track header toggles through the app seam: a `TrackToggleRequested`
/// event lands as ONE undoable engine command, the timeline snapshot
/// reflects the new flag, and undo restores it. Visibility maps onto the
/// track's muted flag (Olive parity).
#[gpui::test]
async fn real_engine_track_toggles_are_undoable(cx: &mut gpui::TestAppContext) {
use gpui::timeline::TrackHeaderEvent;
let _media = media_lock();
let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx)));
cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx)));
cx.update(|app| engine.update(app, |engine, cx| engine.add_track(TrackKind::Video, cx)));
cx.update(|app| engine.update(app, |engine, cx| engine.add_track(TrackKind::Audio, cx)));
// Display order: video tracks first (index 0), then audio (index 1).
assert!(cx.read(|app| engine.read(app).track(0).expect("V1").is_visible()));
assert!(!cx.read(|app| engine.read(app).track(1).expect("A1").is_muted()));
let toggle = |cx: &mut gpui::TestAppContext, track: usize, t: TrackHeaderEvent| {
cx.update(|app| {
engine.update(app, |engine, cx| {
engine.apply_timeline_event(
&TimelineEvent::TrackToggleRequested { track, toggle: t },
cx,
);
})
});
};
toggle(cx, 0, TrackHeaderEvent::ToggleVisibility);
assert!(
!cx.read(|app| engine.read(app).track(0).expect("V1").is_visible()),
"the visibility toggle hides the video track"
);
toggle(cx, 0, TrackHeaderEvent::ToggleLock);
assert!(cx.read(|app| engine.read(app).track(0).expect("V1").is_locked()));
toggle(cx, 1, TrackHeaderEvent::ToggleMute);
assert!(cx.read(|app| engine.read(app).track(1).expect("A1").is_muted()));
// Three toggle commands, three undos.
cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx)));
assert!(!cx.read(|app| engine.read(app).track(1).expect("A1").is_muted()));
cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx)));
assert!(!cx.read(|app| engine.read(app).track(0).expect("V1").is_locked()));
cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx)));
assert!(cx.read(|app| engine.read(app).track(0).expect("V1").is_visible()));
}
/// M12 P2 acceptance: a real project with a sequence + footage clip
/// builds a NON-EMPTY node graph with the wires the node editor shows:
/// the footage feeds the clip's `tex_in` (a real edge), and every clip
+54 -1
View File
@@ -74,7 +74,8 @@ fn clip_media(g: &oaknode::graph::Graph, block_id: NodeId) -> Option<String> {
/// The video montage at sequence time `time`: every clip covering `time`
/// on video tracks, ordered bottom-to-top (track index 0 is topmost, so
/// it is composited last).
/// it is composited last). Hidden tracks (the muted flag doubles as the
/// video visibility toggle, Olive parity) contribute nothing.
pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<MontageClip> {
let g = lock(p);
let mut clips = Vec::new();
@@ -92,6 +93,9 @@ pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<Montage
let Some(track) = track_behavior(&g.graph, track_id) else {
continue;
};
if track.muted {
continue;
}
for &block_id in &track.blocks {
let Some(clip) = clip_behavior(&g.graph, block_id) else {
continue;
@@ -120,6 +124,7 @@ pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<Montage
/// The audio montage over `range`: every audio clip overlapping the
/// range, media times resolved from the clip ranges, audio stream 1.
/// Muted tracks are silenced (skipped entirely).
pub fn audio_montage(p: &ProjectRef, seq: NodeId, range: TimeRange) -> Vec<MontageClip> {
let g = lock(p);
let mut clips = Vec::new();
@@ -137,6 +142,9 @@ pub fn audio_montage(p: &ProjectRef, seq: NodeId, range: TimeRange) -> Vec<Monta
let Some(track) = track_behavior(&g.graph, track_id) else {
continue;
};
if track.muted {
continue;
}
for &block_id in &track.blocks {
let Some(clip) = clip_behavior(&g.graph, block_id) else {
continue;
@@ -532,4 +540,49 @@ mod tests {
oakundo::global::clear().unwrap();
let _ = std::fs::remove_file(&media);
}
/// Hidden video tracks and muted audio tracks (both are the track's
/// `muted` flag) contribute nothing to the montages; undoing the flag
/// set restores them.
#[test]
fn montages_skip_muted_tracks() {
let _media = media_lock();
let media = std::env::temp_dir().join(format!("oakapp_montage_m_{}.mp4", std::process::id()));
oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media");
let (project, seq, footage) = project_with_clip(&media);
graphops::add_track(&project, seq, TrackType::Audio).expect("add an audio track");
graphops::place_footage_clip(&project, seq, footage, TrackType::Audio, 0, 0, 10, 0)
.expect("place the audio clip");
let (video_track, audio_track, tb) = {
let g = lock(&project);
(
graphops::track_ids(&g.graph, seq, TrackType::Video)[0],
graphops::track_ids(&g.graph, seq, TrackType::Audio)[0],
graphops::sequence_time_base(&g.graph, seq).unwrap(),
)
};
let at = |frame: i64| graphops::ts_to_rational(frame, tb);
let range = TimeRange::new(at(0), at(5));
assert_eq!(video_montage(&project, seq, at(0)).len(), 1);
assert_eq!(audio_montage(&project, seq, range).len(), 1);
graphops::set_track_muted(&project, video_track, true).expect("hide the video track");
graphops::set_track_muted(&project, audio_track, true).expect("mute the audio track");
assert!(
video_montage(&project, seq, at(0)).is_empty(),
"a hidden video track contributes nothing"
);
assert!(
audio_montage(&project, seq, range).is_empty(),
"a muted audio track contributes nothing"
);
oakundo::global::undo().unwrap();
oakundo::global::undo().unwrap();
assert_eq!(video_montage(&project, seq, at(0)).len(), 1, "undo restores the video clip");
assert_eq!(audio_montage(&project, seq, range).len(), 1, "undo restores the audio clip");
oakundo::global::clear().unwrap();
let _ = std::fs::remove_file(&media);
}
}