Files
oak-editor/src/oakui/graphops.rs
T
Mike-Solar 29696479b5 fix(app): context-menu actions, clip clipboard, A/V drop, add-track, default tracks, undo divergence
- Right-clicking an unselected clip selects it first (C++ parity) —
  this is what made Cut/Delete appear to do nothing.
- Cut/Copy/Paste clipboard: clipboard_copy/cut/paste on the engine,
  clipboard clips keep footage/range/speed/track kind and stay linked
  in the pasted group; paste lands at the playhead as one undo entry.
- Dropping a video-with-audio footage places the video clip plus a
  linked audio clip at the same range in ONE 'Add Clip' undo entry.
- Add Video/Audio Track buttons in the timeline toolbar and the track
  header context menu; new sequences start with 2 video + 2 audio
  tracks (not an undoable edit).
- oaknode Graph::add_entry now reclaims the slot from the free list —
  before, a detached-then-reattached node left its slot in the free
  list, so node_count undercounted and the next add_node silently
  clobbered the restored node. This was the user's 'undo, redo, undo,
  redo and the result changed' bug; regression covered by cycle tests
  (move/trim/delete/split/add-track/linked-placement all converge).
2026-08-19 17:42:21 +08:00

2241 lines
72 KiB
Rust

// 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/>.
//! Domain helpers over the module graph (M14 R3).
//!
//! The real engine ([`crate::oakui::real`]) drives the oak* module crates
//! directly (no liboakengine C ABI). This module is the app's own assembly
//! layer for the project/timeline/storage domain: project lifecycle (new /
//! load / save over the oaknode serializer), sequence + track + clip
//! queries, footage import, the per-sequence marker-list / workarea
//! auxiliaries, and the project-library operations over oakstorage's
//! write-through backend.
//!
//! The functions mirror the semantics the facade's `oakengine_*` exports
//! had (the facade keeps its own copies for the frozen C ABI); the
//! composition mirrors `crates/oak-cli/src/engine.rs` (M14 R2), which
//! established the same mapping for the CLI.
use std::path::Path;
use std::sync::{Arc, Mutex, MutexGuard};
use oakcore_rs::{Rational, TimeRange};
use oaknode::block::ClipBlockBehavior;
use oaknode::footage::FootageBehavior;
use oaknode::graph::Graph;
use oaknode::id::NodeId;
use oaknode::project::Project;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
use oaktimeline::handle::CHandle;
use oaktimeline::util::NodeRef;
use oakstorage::backend::StorageBackend;
use super::engine::LibraryProject;
/// The shared project reference (the modules' domain project handle).
pub type ProjectRef = Arc<Mutex<Project>>;
/// Lock a project, recovering from a poisoned lock (a panicking command
/// body must not wedge every later edit).
pub fn lock(p: &ProjectRef) -> MutexGuard<'_, Project> {
p.lock().unwrap_or_else(|e| e.into_inner())
}
/// A timeline node reference (the oaktimeline command addressing).
pub fn node_ref(p: &ProjectRef, id: NodeId) -> NodeRef {
NodeRef::new(p.clone(), id)
}
/// The node id whose stable identity is `identity` (`None` for the
/// sentinel). The app's widget-facing ids (clip ids, explorer entry ids)
/// ARE the node identities.
pub fn id_of(identity: u64) -> Option<NodeId> {
NodeId::from_identity(identity)
}
// ---------------------------------------------------------------------------
// Behavior borrows
// ---------------------------------------------------------------------------
/// Borrow the sequence behavior at `id`.
pub fn sequence_behavior(g: &Graph, id: NodeId) -> Option<&SequenceBehavior> {
g.get(id)?
.behavior
.as_any()?
.downcast_ref::<SequenceBehavior>()
}
/// Borrow the track-list behavior at `id`.
pub fn track_list_behavior(g: &Graph, id: NodeId) -> Option<&TrackListBehavior> {
g.get(id)?
.behavior
.as_any()?
.downcast_ref::<TrackListBehavior>()
}
/// Borrow the track behavior at `id`.
pub fn track_behavior(g: &Graph, id: NodeId) -> Option<&TrackBehavior> {
g.get(id)?
.behavior
.as_any()?
.downcast_ref::<TrackBehavior>()
}
/// Borrow the clip behavior at `id`.
pub fn clip_behavior(g: &Graph, id: NodeId) -> Option<&ClipBlockBehavior> {
g.get(id)?
.behavior
.as_any()?
.downcast_ref::<ClipBlockBehavior>()
}
/// Borrow the footage behavior at `id`.
pub fn footage_behavior(g: &Graph, id: NodeId) -> Option<&FootageBehavior> {
g.get(id)?
.behavior
.as_any()?
.downcast_ref::<FootageBehavior>()
}
/// Whether `id` is a folder node.
pub fn is_folder(g: &Graph, id: NodeId) -> bool {
g.get(id)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<oaknode::folder::FolderBehavior>())
.is_some()
}
// ---------------------------------------------------------------------------
// Project lifecycle
// ---------------------------------------------------------------------------
/// Create a blank, initialized project (the facade's `project_create` +
/// `project_new`, minus the undo-stack clear and the storage bind — the
/// engine's adopt path owns those).
pub fn create_project() -> ProjectRef {
let project = Project::new();
lock(&project).initialize().ok();
project
}
/// Load a `.ove` project file (plain XML; the module serializer ignores
/// the legacy compression flag). The filename is normalized to an absolute
/// path and the modified flag cleared, mirroring the facade's
/// `oakengine_project_load`.
pub fn load_ove(path: &Path) -> Result<ProjectRef, String> {
let xml = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read \"{}\": {e}", path.display()))?;
let project =
oaknode::serializer::load(&xml).map_err(|e| format!("failed to parse: {e}"))?;
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map(|d| d.join(path))
.unwrap_or_else(|_| path.to_path_buf())
};
let mut guard = lock(&project);
guard.set_filename(&abs.to_string_lossy());
guard.set_modified(false);
drop(guard);
Ok(project)
}
/// Write the project to `path` through the OVE serializer (the facade's
/// `oakengine_project_save` semantics: the target filename is recorded and
/// the modified flag cleared on success).
pub fn save_ove(project: &ProjectRef, path: &Path) -> Result<(), String> {
let xml = {
let guard = lock(project);
oaknode::serializer::save(&guard).map_err(|e| format!("failed to serialize: {e}"))?
};
std::fs::write(path, &xml).map_err(|e| format!("failed to write \"{}\": {e}", path.display()))?;
let mut guard = lock(project);
guard.set_filename(&path.to_string_lossy());
guard.set_modified(false);
Ok(())
}
/// The display name (`Project::name`: filename base or "(untitled)").
pub fn project_name(p: &Project) -> String {
p.name()
}
// ---------------------------------------------------------------------------
// Sequences
// ---------------------------------------------------------------------------
/// Every sequence node in the graph, in arena order.
pub fn sequence_ids(p: &Project) -> Vec<NodeId> {
p.graph
.node_ids()
.into_iter()
.filter(|&id| sequence_behavior(&p.graph, id).is_some())
.collect()
}
/// Every footage node in the graph, in arena order.
pub fn footage_ids(p: &Project) -> Vec<NodeId> {
p.graph
.node_ids()
.into_iter()
.filter(|&id| footage_behavior(&p.graph, id).is_some())
.collect()
}
/// Create a sequence node named `name` directly in the project's graph
/// (unlike the facade's `oakengine_sequence_new`, which kept the sequence
/// in a scratch project, the direct-rlib app keeps it in the project so
/// saves and the write-through library cover it).
pub fn create_sequence(project: &ProjectRef, name: &str) -> NodeId {
let mut guard = lock(project);
let (mut core, behavior) = SequenceBehavior::create();
core.label = name.to_string();
let seq = guard.graph.add_node(core, behavior);
drop(guard);
// A new sequence starts with the default 2 video + 2 audio track
// layout (user-mandated NLE default: V1, V2 on top, A1, A2 below).
// Driven directly through the add-track commands' redo — NOT pushed
// to the undo stack, a sequence's default layout is part of its
// creation, not an undoable edit.
for kind in [TrackType::Video, TrackType::Video, TrackType::Audio, TrackType::Audio] {
let Some(list) = find_or_create_track_list(project, seq, kind) else {
continue;
};
oaktimeline::undogeneral::TimelineAddTrackCommand::new(node_ref(project, list)).redo();
}
seq
}
/// The label of a node (`NodeCore::label`).
pub fn node_label(g: &Graph, id: NodeId) -> String {
g.get(id).map(|e| e.core.label.clone()).unwrap_or_default()
}
/// The type id of a node (empty when the id is stale).
pub fn node_type_id(g: &Graph, id: NodeId) -> String {
g.get(id)
.map(|e| e.behavior.type_id().to_string())
.unwrap_or_default()
}
/// The sequence's video format `(width, height, rate)` from its first
/// video stream.
pub fn sequence_video_params(g: &Graph, seq: NodeId) -> Option<(i32, i32, Rational)> {
let v = sequence_behavior(g, seq)?.video_params.first()?;
Some((v.width, v.height, v.frame_rate))
}
/// The sequence's frame duration as a `(num, den)` timebase pair (the
/// frame rate flipped; the facade's `seq_time_base`). `None` when the
/// sequence has no valid frame rate.
pub fn sequence_time_base(g: &Graph, seq: NodeId) -> Option<(i64, i64)> {
let (_, _, rate) = sequence_video_params(g, seq)?;
let num = rate.numerator();
let den = rate.denominator();
if num <= 0 || den <= 0 {
return None;
}
Some((den, num))
}
/// The sequence content length (rational seconds): the longest track out
/// point across every track list (the module's `verify_length` overall).
pub fn sequence_length(g: &Graph, seq: NodeId) -> Rational {
let mut best = Rational::new(0, 1);
let Some(s) = sequence_behavior(g, seq) else {
return best;
};
for &list_id in &s.track_lists {
let Some(list) = track_list_behavior(g, list_id) else {
continue;
};
for &track_id in &list.tracks {
let Some(track) = track_behavior(g, track_id) else {
continue;
};
for &block_id in &track.blocks {
let Some(entry) = g.get(block_id) else {
continue;
};
let Some(core) = block_core(entry) else {
continue;
};
let out = core.out();
if out > best {
best = out;
}
}
}
}
best
}
/// Borrow a block's core (any block kind).
fn block_core(entry: &oaknode::graph::NodeEntry) -> Option<&oaknode::block::BlockCore> {
let any = entry.behavior.as_any()?;
if let Some(c) = any.downcast_ref::<ClipBlockBehavior>() {
return Some(&c.core);
}
if let Some(g) = any.downcast_ref::<oaknode::block::GapBlockBehavior>() {
return Some(&g.core);
}
any.downcast_ref::<oaknode::block::TransitionBlockBehavior>()
.map(|t| &t.core)
}
/// The sequence playhead (rational seconds).
pub fn sequence_playhead(g: &Graph, seq: NodeId) -> Rational {
sequence_behavior(g, seq)
.map(|s| s.playhead)
.unwrap_or(Rational::new(0, 1))
}
/// Move the sequence playhead (rational seconds).
pub fn sequence_set_playhead(p: &ProjectRef, seq: NodeId, time: Rational) {
let mut guard = lock(p);
if let Some(s) = guard
.graph
.get_mut(seq)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
{
s.playhead = time;
}
}
// ---------------------------------------------------------------------------
// Frame timestamps <-> rational seconds
// ---------------------------------------------------------------------------
/// Greatest common divisor (1 when both are zero).
fn gcd(a: i64, b: i64) -> i64 {
let (mut a, mut b) = (a.abs(), b.abs());
while b != 0 {
let t = b;
b = a % b;
a = t;
}
if a == 0 {
1
} else {
a
}
}
/// Rational seconds -> timestamp in timebase units, rounding half away
/// from zero (the facade's `rational_to_ts`, `Timecode::k_round`).
pub fn rational_to_ts(r: Rational, tb: (i64, i64)) -> i64 {
let (num, den) = (r.numerator(), r.denominator());
if den == 0 || tb.0 == 0 || tb.1 == 0 {
return 0;
}
let n = num as i128 * tb.1 as i128;
let d = den as i128 * tb.0 as i128;
let q = n / d;
let r = n % d;
let rr = if r < 0 { -r } else { r };
let dd = if d < 0 { -d } else { d };
if rr * 2 >= dd {
(q + if n < 0 { -1 } else { 1 }) as i64
} else {
q as i64
}
}
/// Timestamp -> reduced rational seconds (`time = ts * tb`).
pub fn ts_to_rational(ts: i64, tb: (i64, i64)) -> Rational {
let num = ts as i128 * tb.0 as i128;
let den = tb.1 as i128;
let g = gcd((num % den) as i64, den as i64) as i128;
Rational::new((num / g) as i64, (den / g) as i64)
}
// ---------------------------------------------------------------------------
// Tracks and clips
// ---------------------------------------------------------------------------
/// The sequence's track list of `kind`, when it exists.
pub fn track_list_of(g: &Graph, seq: NodeId, kind: TrackType) -> Option<NodeId> {
for &list_id in &sequence_behavior(g, seq)?.track_lists {
if track_list_behavior(g, list_id).map(|l| l.kind) == Some(kind) {
return Some(list_id);
}
}
None
}
/// Find (or create) the sequence's track list of `kind` (the facade's
/// `oaknode_sequence_get_track_list` find-or-create semantics; mirrors the
/// CLI's `find_or_create_track_list`).
pub fn find_or_create_track_list(p: &ProjectRef, seq: NodeId, kind: TrackType) -> Option<NodeId> {
let mut guard = lock(p);
if let Some(list) = track_list_of(&guard.graph, seq, kind) {
return Some(list);
}
// Create it: a graph node owned by the sequence.
let (core, behavior) = TrackListBehavior::create();
let mut behavior = behavior;
if let Some(a) = behavior.as_any_mut() {
if let Some(list) = a.downcast_mut::<TrackListBehavior>() {
list.kind = kind;
list.array_base = sequence_behavior(&guard.graph, seq)?.track_lists.len() as i32;
}
}
let list_id = guard.graph.add_node(core, behavior);
if let Some(s) = guard
.graph
.get_mut(seq)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
{
s.track_lists.push(list_id);
}
if let Some(l) = guard
.graph
.get_mut(list_id)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
{
l.sequence = Some(seq);
}
Some(list_id)
}
/// The tracks of the sequence's `kind` list, in stack order.
pub fn track_ids(g: &Graph, seq: NodeId, kind: TrackType) -> Vec<NodeId> {
track_list_of(g, seq, kind)
.and_then(|list| track_list_behavior(g, list).map(|l| l.tracks.clone()))
.unwrap_or_default()
}
/// The clip blocks of a track (gaps skipped), in timeline order.
pub fn clip_ids(g: &Graph, track: NodeId) -> Vec<NodeId> {
track_behavior(g, track)
.map(|t| {
t.blocks
.iter()
.copied()
.filter(|&b| clip_behavior(g, b).is_some())
.collect()
})
.unwrap_or_default()
}
/// A clip's timeline range and media in-point (rational seconds).
pub fn clip_range(g: &Graph, clip: NodeId) -> Option<(Rational, Rational, Rational)> {
let c = clip_behavior(g, clip)?;
Some((c.core.in_(), c.core.out(), c.core.media_in))
}
/// The clip's owning track.
pub fn clip_track(g: &Graph, clip: NodeId) -> Option<NodeId> {
clip_behavior(g, clip)?.core.track
}
/// The first footage node feeding `id` (upstream BFS over input edges),
/// mirroring the facade's `oaknode_node_find_input_footage`.
pub fn find_input_footage(g: &Graph, id: NodeId) -> Option<NodeId> {
let mut frontier = vec![id];
let mut visited: Vec<NodeId> = Vec::new();
while !frontier.is_empty() {
let mut next = Vec::new();
for cur in frontier {
if visited.contains(&cur) {
continue;
}
visited.push(cur);
let entry = g.get(cur)?;
if entry.behavior.type_id() == "org.olivevideoeditor.Olive.footage" && cur != id {
return Some(cur);
}
for (src, _, _) in g.input_connections(cur) {
next.push(src);
}
}
frontier = next;
}
None
}
/// The media filename feeding a clip (upstream BFS + footage behavior).
pub fn clip_media_filename(g: &Graph, clip: NodeId) -> Option<String> {
let footage = find_input_footage(g, clip)?;
Some(footage_behavior(g, footage)?.filename.clone())
}
// ---------------------------------------------------------------------------
// Footage
// ---------------------------------------------------------------------------
/// The footage's probed duration in seconds (`None` when unprobed).
pub fn footage_duration_seconds(g: &Graph, id: NodeId) -> Option<f64> {
let d = footage_behavior(g, id)?.duration();
let den = d.denominator();
if den == 0 {
return None;
}
let seconds = d.numerator() as f64 / den as f64;
(seconds > 0.0).then_some(seconds)
}
/// Reprobe footage whose stream metadata is missing: projects saved
/// before the probe recorded streams (C++ files have no `<streams>`
/// segment at all) load with an empty inventory, which leaves the
/// footage duration unknown. Relative filenames resolve against the
/// project file's directory (the C++ project-dir convention) and are
/// made absolute on the node. Best effort per node: an unreadable or
/// missing file stays unprobed.
pub fn reprobe_unprobed_footage(project: &ProjectRef) {
let dir = {
let guard = lock(project);
std::path::Path::new(guard.filename())
.parent()
.map(|p| p.to_path_buf())
};
let targets: Vec<(NodeId, std::path::PathBuf)> = {
let guard = lock(project);
footage_ids(&guard)
.into_iter()
.filter_map(|id| {
let f = footage_behavior(&guard.graph, id)?;
if !f.streams.is_empty() || f.filename.is_empty() {
return None;
}
let path = std::path::Path::new(&f.filename);
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
dir.as_ref()?.join(path)
};
resolved.is_file().then_some((id, resolved))
})
.collect()
};
for (id, resolved) in targets {
let mut guard = lock(project);
if let Some(f) = guard
.graph
.get_mut(id)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<FootageBehavior>())
{
f.filename = resolved.to_string_lossy().into_owned();
// Best effort: a failed probe leaves the footage unprobed
// (valid stays false), exactly like a failed import probe.
let _ = f.probe();
}
}
}
/// Import a media file into the project's root folder (the facade's
/// `oakengine_project_import_footage`: the node is created in the graph
/// and one undoable "Import Footage" entry adds it to the root folder).
/// Media that fails the probe (missing, corrupt, or undecodable) is
/// rejected before it ever enters the graph — the facade's validity
/// rejection, which the C API skips.
pub fn import_footage(project: &ProjectRef, path: &Path) -> Result<NodeId, String> {
if !path.exists() {
return Err(format!("file does not exist: {}", path.display()));
}
let filename = path.to_string_lossy().into_owned();
let (root, id) = {
let mut guard = lock(project);
if !guard.root.valid() {
return Err("the project has no root folder".to_string());
}
let (mut core, mut behavior) = FootageBehavior::create();
core.set_standard_value("file_in", -1, oaknode::value::NodeValue::Text(filename.clone()));
let Some(f) = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<FootageBehavior>())
else {
return Err("internal error: footage node created without footage behavior".to_string());
};
f.filename = filename.clone();
// Probe before the node enters the graph so a failed probe leaves
// no orphan behind (and nothing lands on the undo stack).
f.probe()
.map_err(|e| format!("failed to probe \"{}\": {e}", filename))?;
let id = guard.graph.add_node(core, behavior);
let label = path
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| filename.clone());
if let Some(e) = guard.graph.get_mut(id) {
e.core.label = label;
}
(guard.root, id)
};
let cmd = oaktask::nodeops::folder_add_child_command(
(project.clone(), root),
(project.clone(), id),
);
oakundo::global::push(cmd, "Import Footage").map_err(|e| e.to_string())?;
Ok(id)
}
// ---------------------------------------------------------------------------
// Marker list / workarea auxiliaries
//
// The module's sequences never initialize their own marker/workarea state
// (`SequenceBehavior` defaults both to empty handles), so the app — like
// the facade before it — materializes one of each per open sequence. The
// engine owns the handles and releases them before the project drops.
// ---------------------------------------------------------------------------
/// Create a marker-list handle (owned, refcount 1).
pub fn marker_list_create() -> CHandle {
oaktimeline::handle::make_owned(oaktimeline::marker::TimelineMarkerList::new())
}
/// Create a workarea handle (owned, refcount 1).
pub fn workarea_create() -> CHandle {
oaktimeline::handle::make_owned(oaktimeline::workarea::TimelineWorkArea::new())
}
/// Release an owned marker-list / workarea handle (NULL-safe; the handle
/// is dead afterwards).
pub fn release_handle(h: &mut CHandle) {
if let Some(release) = h.release {
// SAFETY: `h` is an owned handle from `marker_list_create` /
// `workarea_create`; the release runs the box's own destructor once
// per owned reference, and the caller drops the handle afterwards.
unsafe { release(h.ctx) };
}
h.ctx = std::ptr::null_mut();
}
/// One marker as the timeline shows it: in-point, name, color index.
pub fn markers_of(list: &CHandle) -> Vec<(Rational, String, i32)> {
if list.is_null() {
return Vec::new();
}
// SAFETY: `list` boxes a `TimelineMarkerList` (created by
// `marker_list_create`); the read is shared and brief.
let Some(l) = (unsafe {
oaktimeline::handle::get::<std::sync::Arc<std::sync::Mutex<oaktimeline::marker::TimelineMarkerList>>>(
list,
)
}) else {
return Vec::new();
};
let l = l.lock().unwrap_or_else(|e| e.into_inner());
(0..l.size())
.filter_map(|i| l.at(i))
.map(|m| (m.time().in_(), m.name().to_string(), m.color()))
.collect()
}
/// The index of the first marker whose in-point equals `time`.
pub fn marker_index_at(list: &CHandle, time: Rational) -> Option<usize> {
if list.is_null() {
return None;
}
// SAFETY: as `markers_of`.
let l = unsafe {
oaktimeline::handle::get::<std::sync::Arc<std::sync::Mutex<oaktimeline::marker::TimelineMarkerList>>>(
list,
)
}?;
let l = l.lock().unwrap_or_else(|e| e.into_inner());
(0..l.size()).find(|&i| l.at(i).map(|m| m.time().in_()) == Some(time))
}
/// The workarea's `(enabled, range)`.
pub fn workarea_state(wa: &CHandle) -> Option<(bool, TimeRange)> {
if wa.is_null() {
return None;
}
// SAFETY: `wa` boxes a `TimelineWorkArea` (created by
// `workarea_create`); the read is shared and brief.
let w = unsafe {
oaktimeline::handle::get::<std::sync::Arc<std::sync::Mutex<oaktimeline::workarea::TimelineWorkArea>>>(
wa,
)
}?;
let w = w.lock().unwrap_or_else(|e| e.into_inner());
Some((w.enabled(), *w.range()))
}
/// Live (non-undoable) workarea write: enable flag plus range.
pub fn workarea_set(wa: &CHandle, enabled: bool, range: TimeRange) {
if wa.is_null() {
return;
}
// SAFETY: `wa` boxes a `TimelineWorkArea`; the engine writes it only
// from the UI thread.
if let Some(w) = unsafe {
oaktimeline::handle::get_mut::<std::sync::Arc<std::sync::Mutex<oaktimeline::workarea::TimelineWorkArea>>>(
wa,
)
} {
let mut w = w.lock().unwrap_or_else(|e| e.into_inner());
w.set_enabled(enabled);
w.set_range(range);
}
}
// ---------------------------------------------------------------------------
// Project library (M13 D4): the write-through database the manager browses
//
// The facade's library_* exports serialized rows to JSON only because they
// crossed the C ABI; the direct calls below return plain values.
// ---------------------------------------------------------------------------
/// The configured default library as a parsed URI; an error when the
/// write-through backend is disabled or the path does not resolve.
fn library() -> Result<oakstorage::uri::StorageUri, String> {
if !oakstorage::writethrough::storage_enabled() {
return Err("the project library is not configured".to_string());
}
let uri = oakstorage::writethrough::library_uri()
.ok_or_else(|| "the project library path does not resolve".to_string())?;
oakstorage::uri::StorageUri::parse(&uri).map_err(|e| e.to_string())
}
/// The library URI selecting one row (`…?project=<uuid>`).
fn project_uri(uuid: &str) -> Result<oakstorage::uri::StorageUri, String> {
let uri = library()?;
oakstorage::uri::StorageUri::parse(&format!("{}?project={uuid}", uri.to_uri_string()))
.map_err(|e| e.to_string())
}
/// The library rows, most recently modified first (the project manager's
/// data source). With storage disabled the result is the empty list, not
/// an error (the facade contract).
pub fn library_list() -> Result<Vec<LibraryProject>, String> {
if !oakstorage::writethrough::storage_enabled() {
return Ok(Vec::new());
}
let uri = library()?;
let backend = oakstorage::writethrough::backend();
let infos = backend.list_projects(&uri).map_err(|e| e.to_string())?;
Ok(infos
.into_iter()
.map(|info| {
let stats = backend.project_stats(&uri, &info.uuid).unwrap_or_default();
LibraryProject {
uuid: info.uuid,
name: info.name,
created_at: info.created_at.and_utc().timestamp(),
modified_at: info.modified_at.and_utc().timestamp(),
duration_ms: stats.duration_ms,
track_count: stats.track_count,
clip_count: stats.clip_count,
footage_count: stats.footage_count,
}
})
.collect())
}
/// Create a blank project row named `name`; returns its uuid. The row
/// lands immediately (one `kind='import'` command), so the manager list
/// shows it before the first edit.
pub fn library_create(name: &str) -> Result<String, String> {
if name.trim().is_empty() {
return Err("invalid name".to_string());
}
let uri = library()?;
let project = create_project();
let uuid = {
let mut guard = lock(&project);
guard
.settings
.insert("projectname".to_string(), name.to_string());
guard.uuid.clone()
};
let result = oakstorage::writethrough::backend()
.save_project(&project, &uri, 0)
.map_err(|e| e.to_string());
result?;
Ok(uuid)
}
/// Delete the library row `uuid` (cascades settings / snapshots /
/// journal).
pub fn library_delete(uuid: &str) -> Result<(), String> {
if uuid.is_empty() {
return Err("invalid uuid".to_string());
}
oakstorage::writethrough::backend()
.delete_project(&library()?, uuid)
.map_err(|e| e.to_string())
}
/// Rename the library row `uuid` (the manager's list name).
pub fn library_rename(uuid: &str, name: &str) -> Result<(), String> {
if uuid.is_empty() || name.trim().is_empty() {
return Err("invalid uuid or name".to_string());
}
oakstorage::writethrough::backend()
.rename_project(&library()?, uuid, name.trim())
.map_err(|e| e.to_string())
}
/// Duplicate the library row `uuid` (history included) under a fresh
/// uuid; returns the new row's uuid. `None` name defaults to
/// `<name> (copy)` backend-side.
pub fn library_duplicate(uuid: &str) -> Result<String, String> {
if uuid.is_empty() {
return Err("invalid uuid".to_string());
}
let info = oakstorage::writethrough::backend()
.duplicate_project(&library()?, uuid, None)
.map_err(|e| e.to_string())?;
Ok(info.uuid)
}
/// Import a `.ove` / `.otio` / `.fcpxml` project file as a new library
/// row; returns the new row's uuid.
pub fn library_import(path: &Path) -> Result<String, String> {
let file_uri = oakstorage::uri::StorageUri::parse(&path.to_string_lossy())
.map_err(|e| e.to_string())?;
oakstorage::writethrough::backend()
.import_from_file(&library()?, &file_uri)
.map_err(|e| e.to_string())
}
/// Export the library row `uuid` to `path`; the format is dispatched by
/// extension through the oakstorage registry.
pub fn library_export(uuid: &str, path: &Path) -> Result<(), String> {
if uuid.is_empty() {
return Err("invalid uuid".to_string());
}
let file_uri = oakstorage::uri::StorageUri::parse(&path.to_string_lossy())
.map_err(|e| e.to_string())?;
oakstorage::writethrough::backend()
.export_to_file(&library()?, uuid, &file_uri)
.map_err(|e| e.to_string())
}
/// Load the library row `uuid` as a fresh project (the modified flag is
/// cleared, mirroring the facade's `oakengine_project_load_library`; the
/// undo-stack clear and the storage bind are the engine adopt path's job).
pub fn library_open(uuid: &str) -> Result<ProjectRef, String> {
if uuid.is_empty() {
return Err("invalid uuid".to_string());
}
let result = oakstorage::writethrough::backend()
.load(&project_uri(uuid)?)
.map_err(|e| e.to_string())?;
if result.project.is_null() {
return Err(format!(
"library load of {uuid} returned no project (info code {})",
result.version_info
));
}
let handle = result.project;
let project = unsafe { oakstorage::nodeutil::project_arc(&handle) }
.map_err(|e| e.to_string())?;
lock(&project).set_modified(false);
Ok(project)
}
// ---------------------------------------------------------------------------
// Write-through binding (the engine's per-project session state)
// ---------------------------------------------------------------------------
/// Bind `project` to the configured default library and return the handle
/// the binding was registered under (the caller keeps it for
/// [`storage_bound`] / [`storage_last_error`] queries and releases it with
/// [`storage_unbind`]). No-op (None still returned) semantics mirror the
/// facade: an unconfigured library leaves the project unbound but the
/// handle is still usable for queries.
pub fn storage_bind(project: &ProjectRef) -> CHandle {
let handle = oakstorage::nodeutil::make_project_owned(project.clone());
oakstorage::writethrough::bind_project(handle);
handle
}
/// Whether the project behind `handle` is bound to the write-through
/// session (the status bar's write state).
pub fn storage_bound(handle: &CHandle) -> bool {
oakstorage::writethrough::is_bound(*handle)
}
/// The last write-through / snapshot error of the project behind
/// `handle`, if any.
pub fn storage_last_error(handle: &CHandle) -> Option<String> {
oakstorage::writethrough::last_error(*handle)
}
/// Flush the project's pending writes, drop its binding and release the
/// query handle (the engine's project-close path).
pub fn storage_unbind(handle: CHandle) {
oakstorage::writethrough::unbind_project(handle);
oakstorage::nodeutil::release_project(handle);
}
/// Flush every bound project and stop the snapshot thread (app exit).
pub fn storage_flush() {
oakstorage::writethrough::flush_all();
}
// ---------------------------------------------------------------------------
// Undoable edit primitives
//
// The timeline edits the facade's `oakengine_sequence_*` / `oakengine_clip_*`
// exports performed, rebuilt over the oaktimeline command structs and the
// oakundo global stack's safe [`oakundo::global::push`]. Every entry point
// pushes exactly one undo row (composite edits assemble a multi command).
// ---------------------------------------------------------------------------
/// Push one command onto the global undo stack (redo then record).
pub fn push_command(cmd: oakundo::undocommand::UndoCommand, name: &str) -> Result<(), String> {
oakundo::global::push(cmd, name).map_err(|e| e.to_string())
}
/// Assemble `children` into ONE multi command and push it (an empty set
/// is a no-op, mirroring the stack's empty-multi discard).
pub fn push_multi_command(
children: Vec<oakundo::undocommand::UndoCommand>,
name: &str,
) -> Result<(), String> {
if children.is_empty() {
return Ok(());
}
let mut multi = oakundo::undocommand::UndoCommand::multi();
for child in children {
multi.multi_add_child(child);
}
push_command(multi, name)
}
/// Push one command (private alias).
fn push(cmd: oakundo::undocommand::UndoCommand, name: &str) -> Result<(), String> {
push_command(cmd, name)
}
/// Assemble and push a multi command (private alias).
fn push_multi(children: Vec<oakundo::undocommand::UndoCommand>, name: &str) -> Result<(), String> {
push_multi_command(children, name)
}
/// An undoable edge add (the module's `oaknode_node_connect_undoable`
/// semantics): validated at creation (existence, connectability, not
/// already connected); the redo connects, the undo disconnects the input.
pub fn connect_command(
p: &ProjectRef,
from: NodeId,
to: NodeId,
input_id: &str,
) -> Result<oakundo::undocommand::UndoCommand, String> {
{
let g = lock(p);
if !g.graph.is_valid(from) || !g.graph.is_valid(to) {
return Err("connect: node not found".to_string());
}
let entry = g.graph.get(to).ok_or("connect: node not found")?;
let input = entry
.core
.get_input(input_id)
.ok_or_else(|| format!("connect: no input \"{input_id}\""))?;
if !input.is_connectable() {
return Err(format!("connect: input \"{input_id}\" is not connectable"));
}
if g.graph.connected_output(to, input_id, -1).is_some() {
return Err(format!("connect: input \"{input_id}\" is already connected"));
}
}
let (p1, p2) = (p.clone(), p.clone());
let (id1, id2) = (input_id.to_string(), input_id.to_string());
Ok(oakundo::undocommand::UndoCommand::from_closures(
move || {
let mut g = lock(&p1);
let _ = g.graph.connect(from, to, &id1, -1);
},
move || {
let mut g = lock(&p2);
g.graph.disconnect_input(to, &id2, -1);
},
))
}
/// An undoable edge remove: succeeds even when nothing is connected (the
/// redo is then a no-op, mirroring the C++ command's redo swallowing). The
/// undo re-connects the edge captured at construction — the module's
/// `oaknode_node_disconnect_undoable` did not model this (documented
/// deviation); the app retains the source node, so its effect-chain edits
/// undo faithfully.
pub fn disconnect_command(
p: &ProjectRef,
to: NodeId,
input_id: &str,
) -> Result<oakundo::undocommand::UndoCommand, String> {
let source = {
let g = lock(p);
let has_input = g
.graph
.get(to)
.map(|e| e.core.has_input(input_id))
.unwrap_or(false);
if !has_input {
return Err(format!("disconnect: no input \"{input_id}\""));
}
g.graph.connected_output(to, input_id, -1)
};
let (p1, p2) = (p.clone(), p.clone());
let (id1, id2) = (input_id.to_string(), input_id.to_string());
Ok(oakundo::undocommand::UndoCommand::from_closures(
move || {
let mut g = lock(&p1);
g.graph.disconnect_input(to, &id1, -1);
},
move || {
if let Some(from) = source {
let mut g = lock(&p2);
let _ = g.graph.connect(from, to, &id2, -1);
}
},
))
}
/// An undoable context-position set (the facade's
/// `oakengine_node_set_context_position`): the first entry is established
/// by the redo itself; the undo restores the previous position or removes
/// the entry it created.
pub fn set_context_position_command(
p: &ProjectRef,
node: NodeId,
context: NodeId,
x: f64,
y: f64,
) -> Result<oakundo::undocommand::UndoCommand, String> {
let old = {
let g = lock(p);
if !g.graph.is_valid(node) || !g.graph.is_valid(context) {
return Err("set position: node not found".to_string());
};
g.graph
.get(node)
.and_then(|e| {
e.core
.context_positions
.iter()
.find(|(c, _, _)| *c == context)
.map(|(_, pos, expanded)| (*pos, *expanded))
})
};
let (p1, p2) = (p.clone(), p.clone());
Ok(oakundo::undocommand::UndoCommand::from_closures(
move || {
let mut g = lock(&p1);
if let Some(e) = g.graph.get_mut(node) {
e.core.set_context_position(context, x, y, false);
}
},
move || {
let mut g = lock(&p2);
if let Some(e) = g.graph.get_mut(node) {
match old {
Some(((ox, oy), expanded)) => {
e.core.set_context_position(context, ox, oy, expanded);
}
None => {
e.core.remove_from_context(context);
}
}
}
},
))
}
/// Append a track of `kind` to the sequence (undoable "Add Track"; the
/// module's `TimelineAddTrackCommand`), returning the new track's index.
pub fn add_track(p: &ProjectRef, seq: NodeId, kind: TrackType) -> Result<usize, String> {
let list = find_or_create_track_list(p, seq, kind)
.ok_or_else(|| "sequence has no track list for this type".to_string())?;
push(
oaktimeline::undogeneral::TimelineAddTrackCommand::new(node_ref(p, list)).to_command(),
"Add Track",
)?;
let g = lock(p);
let n = track_list_behavior(&g.graph, list)
.map(|l| l.tracks.len())
.ok_or_else(|| "add track command produced no track list".to_string())?;
if n == 0 {
return Err("add track command produced no track".to_string());
}
Ok(n - 1)
}
/// Remove `track` from its list (undoable "Remove Track"; the module's
/// `TimelineRemoveTrackCommand` redo performs the full list+graph
/// removal, so no live compensation is needed).
pub fn remove_track(p: &ProjectRef, track: NodeId) -> Result<(), String> {
push(
oaktimeline::undogeneral::TimelineRemoveTrackCommand::new(node_ref(p, track)).to_command(),
"Remove Track",
)
}
/// A track's height in internal units (`None` when the id is stale).
pub fn track_height(p: &ProjectRef, track: NodeId) -> Option<f64> {
let g = lock(p);
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) {
if height <= 0.0 {
return;
}
let mut g = lock(p);
if let Some(t) = g
.graph
.get_mut(track)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<TrackBehavior>())
{
t.height = height;
}
}
/// Place a clip of `footage` on track `track_index` of the sequence's
/// `kind` list (undoable "Add Clip", one row): the clip block is created
/// in the project, placed by the module's `TrackPlaceBlockCommand`, and
/// the footage is wired to the clip's `tex_in` — the facade's
/// `oakengine_sequence_add_footage_clip_ex` composition, minus the
/// scratch-project dance (the app keeps everything in one project).
///
/// `in_ts`/`out_ts`/`media_in_ts` are frame timestamps in the sequence's
/// frame-rate timebase. On undo the block is detached from the track but
/// left as an orphan node in the project graph (the module command's
/// documented behavior).
pub fn place_footage_clip(
p: &ProjectRef,
seq: NodeId,
footage: NodeId,
kind: TrackType,
track_index: usize,
in_ts: i64,
out_ts: i64,
media_in_ts: i64,
) -> Result<NodeId, String> {
if in_ts < 0 || out_ts <= in_ts || media_in_ts < 0 {
return Err("invalid clip range (need 0 <= in < out and media_in >= 0)".to_string());
}
if kind != TrackType::Video && kind != TrackType::Audio {
return Err("clips are only supported on video and audio tracks".to_string());
}
let (tb, list) = {
let g = lock(p);
if footage_behavior(&g.graph, footage).is_none() {
return Err("the footage node is not in the project".to_string());
}
let tb = sequence_time_base(&g.graph, seq)
.ok_or_else(|| "sequence has no valid frame rate".to_string())?;
let list = track_list_of(&g.graph, seq, kind)
.ok_or_else(|| "sequence has no track list for this type".to_string())?;
(tb, list)
};
let track_count = {
let g = lock(p);
track_list_behavior(&g.graph, list)
.map(|l| l.tracks.len())
.unwrap_or(0)
};
if track_index >= track_count {
return Err(format!(
"track index {track_index} out of range ({track_count} tracks)"
));
}
let in_r = ts_to_rational(in_ts, tb);
let out_r = ts_to_rational(out_ts, tb);
let media_r = ts_to_rational(media_in_ts, tb);
let length = out_r - in_r;
// The clip block, positioned by media-in + length (the facade's
// `oaknode_clip_set_media_in` + `oaknode_block_set_length_and_media_in`).
let clip = {
let mut g = lock(p);
let (core, behavior) = oaknode::block::clip_create();
let id = g.graph.add_node(core, behavior);
if let Some(c) = g
.graph
.get_mut(id)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
{
c.core.media_in = media_r;
c.core.set_length_and_media_in(length);
}
id
};
let place = oaktimeline::undopointer::TrackPlaceBlockCommand::new(
node_ref(p, list),
track_index as i32,
node_ref(p, clip),
in_r,
)
.to_command();
let edge = connect_command(p, footage, clip, oaknode::block::clip_input::TEXTURE_INPUT)?;
push_multi(vec![place, edge], "Add Clip")?;
Ok(clip)
}
/// Place linked clips of one footage on several tracks in ONE undoable
/// "Add Clip" entry (the NLE A/V-drop: a video-with-audio file lands as
/// a video clip plus a linked audio clip). `placements` lists the
/// `(track kind, track index)` targets in order; every clip shares the
/// same timeline range and media-in, and all clips are linked both ways
/// (C++ `block_links_` semantics: grouped edits like split/ripple apply
/// to the whole group).
pub fn place_footage_clips_linked(
p: &ProjectRef,
seq: NodeId,
footage: NodeId,
placements: &[(TrackType, usize)],
in_ts: i64,
out_ts: i64,
media_in_ts: i64,
) -> Result<Vec<NodeId>, String> {
if placements.len() < 2 {
return Err("linked placement needs at least two tracks".to_string());
}
let tb = {
let g = lock(p);
if footage_behavior(&g.graph, footage).is_none() {
return Err("the footage node is not in the project".to_string());
}
sequence_time_base(&g.graph, seq)
.ok_or_else(|| "sequence has no valid frame rate".to_string())?
};
let in_r = ts_to_rational(in_ts, tb);
let out_r = ts_to_rational(out_ts, tb);
let media_r = ts_to_rational(media_in_ts, tb);
let length = out_r - in_r;
// Create all clips first (graph writes are not undoable; the placement
// commands below are).
let mut clips = Vec::with_capacity(placements.len());
for _ in placements {
let mut g = lock(p);
let (core, behavior) = oaknode::block::clip_create();
let id = g.graph.add_node(core, behavior);
if let Some(c) = g
.graph
.get_mut(id)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
{
c.core.media_in = media_r;
c.core.set_length_and_media_in(length);
}
clips.push(id);
}
let mut commands = Vec::new();
for (&(kind, track_index), &clip) in placements.iter().zip(&clips) {
let list = {
let g = lock(p);
track_list_of(&g.graph, seq, kind)
.ok_or_else(|| "sequence has no track list for this type".to_string())?
};
commands.push(
oaktimeline::undopointer::TrackPlaceBlockCommand::new(
node_ref(p, list),
track_index as i32,
node_ref(p, clip),
in_r,
)
.to_command(),
);
commands.push(connect_command(p, footage, clip, oaknode::block::clip_input::TEXTURE_INPUT)?);
}
// Link the group both ways (closure commands capture the current
// links for undo).
for (i, &a) in clips.iter().enumerate() {
for (j, &b) in clips.iter().enumerate() {
if i == j {
continue;
}
let old: Vec<NodeId> = {
let g = lock(p);
g.graph.links_of(a)
};
let (p1, p2) = (p.clone(), p.clone());
commands.push(oakundo::undocommand::UndoCommand::from_closures(
move || {
let mut g = lock(&p1);
if let Some(entry) = g.graph.get_mut(a) {
let links = &mut entry.core.links;
if !links.contains(&b) {
links.push(b);
}
}
},
move || {
let mut g = lock(&p2);
if let Some(entry) = g.graph.get_mut(a) {
entry.core.links = old.clone();
}
},
));
}
}
push_multi(commands, "Add Clip")?;
Ok(clips)
}
/// Split `clip` at `time_ts` (a frame timestamp strictly inside the
/// clip's range), undoable "Split Clip" (the module's
/// `BlockSplitCommand`).
pub fn split_clip(p: &ProjectRef, clip: NodeId, time_ts: i64) -> Result<(), String> { let (tb, in_r, out_r) = {
let g = lock(p);
let tb = clip_track(&g.graph, clip)
.and_then(|t| track_behavior(&g.graph, t))
.and_then(|t| t.track_list)
.and_then(|l| track_list_behavior(&g.graph, l))
.and_then(|l| l.sequence)
.and_then(|s| sequence_time_base(&g.graph, s))
.ok_or_else(|| "the clip's sequence has no valid frame rate".to_string())?;
let (in_r, out_r, _) = clip_range(&g.graph, clip)
.ok_or_else(|| "the node is not a clip".to_string())?;
(tb, in_r, out_r)
};
let point = ts_to_rational(time_ts, tb);
if point <= in_r || point >= out_r {
return Err(format!("split time {time_ts} is not strictly inside the clip"));
}
push(
oaktimeline::undosplit::BlockSplitCommand::new(node_ref(p, clip), point).to_command(),
"Split Clip",
)
}
/// Trim `clip`'s timeline range to `[new_in_ts, new_out_ts)` (undoable
/// "Trim Clip"; the facade's `oakengine_clip_trim` semantics: one end at
/// a time, trim-in anchors the OUT, trim-out anchors the IN — the
/// module's own `BlockTrimCommand` applies its length setters with
/// inverted semantics, so the closures carry the correct mapping).
pub fn trim_clip(p: &ProjectRef, clip: NodeId, new_in_ts: i64, new_out_ts: i64) -> Result<(), String> {
use oaknode::block::BlockCore;
if new_in_ts < 0 || new_out_ts <= new_in_ts {
return Err("invalid trim range (need 0 <= new_in < new_out)".to_string());
}
let (tb, old_in, old_out, old_length) = {
let g = lock(p);
let tb = clip_track(&g.graph, clip)
.and_then(|t| track_behavior(&g.graph, t))
.and_then(|t| t.track_list)
.and_then(|l| track_list_behavior(&g.graph, l))
.and_then(|l| l.sequence)
.and_then(|s| sequence_time_base(&g.graph, s))
.ok_or_else(|| "the clip's sequence has no valid frame rate".to_string())?;
let (in_r, out_r, _) =
clip_range(&g.graph, clip).ok_or_else(|| "the node is not a clip".to_string())?;
let length = out_r - in_r;
(tb, in_r, out_r, length)
};
if new_in_ts == rational_to_ts(old_in, tb) && new_out_ts == rational_to_ts(old_out, tb) {
return Ok(());
}
let new_in = ts_to_rational(new_in_ts, tb);
let new_out = ts_to_rational(new_out_ts, tb);
/// A trim closure command: `set` applies the length (in anchored or
/// out anchored) for both redo and undo with the captured values.
fn trim_cmd(
p: &ProjectRef,
clip: NodeId,
out_anchored: bool,
old: Rational,
new: Rational,
) -> oakundo::undocommand::UndoCommand {
let (p1, p2) = (p.clone(), p.clone());
oakundo::undocommand::UndoCommand::from_closures(
move || {
let mut g = lock(&p1);
if let Some(c) = g
.graph
.get_mut(clip)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
{
if out_anchored {
BlockCore::set_length_and_media_out(&mut c.core, new);
} else {
BlockCore::set_length_and_media_in(&mut c.core, new);
}
}
},
move || {
let mut g = lock(&p2);
if let Some(c) = g
.graph
.get_mut(clip)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
{
if out_anchored {
BlockCore::set_length_and_media_out(&mut c.core, old);
} else {
BlockCore::set_length_and_media_in(&mut c.core, old);
}
}
},
)
}
let mut children = Vec::new();
if new_in != old_in {
// in-trim: length = block out - new in (out anchored).
children.push(trim_cmd(p, clip, true, old_length, old_out - new_in));
}
if new_out != old_out {
// out-trim: length = new out - new in (in anchored); the old
// length is the post-in-trim length (out - new in) when both ends
// move.
let post_in_trim = old_out - new_in;
children.push(trim_cmd(p, clip, false, post_in_trim, new_out - new_in));
}
push_multi(children, "Trim Clip")
}
/// Move `clip` within its track so its in point becomes `new_in_ts`
/// (undoable "Move Clip"; the module's `TrackMoveBlockCommand` — the old
/// spot becomes a gap, length and media-in are preserved).
pub fn move_clip(p: &ProjectRef, clip: NodeId, new_in_ts: i64) -> Result<(), String> {
if new_in_ts < 0 {
return Err("invalid move target".to_string());
}
let (tb, list, track_index) = {
let g = lock(p);
let track = clip_track(&g.graph, clip).ok_or_else(|| "the clip is not on a track".to_string())?;
let list = track_behavior(&g.graph, track)
.and_then(|t| t.track_list)
.ok_or_else(|| "the clip's track has no list".to_string())?;
let track_index = track_behavior(&g.graph, track).map(|t| t.index).unwrap_or(0);
let tb = track_list_behavior(&g.graph, list)
.and_then(|l| l.sequence)
.and_then(|s| sequence_time_base(&g.graph, s))
.ok_or_else(|| "the sequence has no valid frame rate".to_string())?;
(tb, list, track_index)
};
push(
oaktimeline::undopointer::TrackMoveBlockCommand::new(
node_ref(p, list),
track_index,
node_ref(p, clip),
ts_to_rational(new_in_ts, tb),
)
.to_command(),
"Move Clip",
)
}
/// Move `clip` to a different track at `new_in_ts` (undoable "Move Clip
/// to Track", one row): the source spot becomes a gap, the block's in
/// point is re-homed, and the clip is placed on the destination track
/// (the facade's `oakengine_sequence_move_clip_to_track` composition).
pub fn move_clip_to_track(
p: &ProjectRef,
clip: NodeId,
dest_track: NodeId,
new_in_ts: i64,
) -> Result<(), String> {
if new_in_ts < 0 {
return Err("invalid move target".to_string());
}
let (tb, list, dest_index, source_track) = {
let g = lock(p);
let source_track =
clip_track(&g.graph, clip).ok_or_else(|| "the clip is not on a track".to_string())?;
let list = track_behavior(&g.graph, dest_track)
.and_then(|t| t.track_list)
.ok_or_else(|| "the destination track has no list".to_string())?;
let dest_index = track_behavior(&g.graph, dest_track)
.map(|t| t.index)
.unwrap_or(0);
let tb = track_list_behavior(&g.graph, list)
.and_then(|l| l.sequence)
.and_then(|s| sequence_time_base(&g.graph, s))
.ok_or_else(|| "the sequence has no valid frame rate".to_string())?;
(tb, list, dest_index, source_track)
};
let in_r = ts_to_rational(new_in_ts, tb);
let gap = oaktimeline::undogeneral::TrackReplaceBlockWithGapCommand::new(
node_ref(p, source_track),
node_ref(p, clip),
true,
)
.to_command();
// The module stores a block's position on the block itself, so the
// place command alone would keep the old in point: re-home it between
// the gap and the place (the facade's BlockInCmdData step).
let old_in = {
let g = lock(p);
clip_range(&g.graph, clip)
.map(|(in_r, _, _)| in_r)
.ok_or_else(|| "the node is not a clip".to_string())?
};
let (p1, p2) = (p.clone(), p.clone());
let rehome = oakundo::undocommand::UndoCommand::from_closures(
move || {
let mut g = lock(&p1);
if let Some(c) = g
.graph
.get_mut(clip)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
{
c.core.set_in(in_r);
}
},
move || {
let mut g = lock(&p2);
if let Some(c) = g
.graph
.get_mut(clip)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
{
c.core.set_in(old_in);
}
},
);
let place = oaktimeline::undopointer::TrackPlaceBlockCommand::new(
node_ref(p, list),
dest_index,
node_ref(p, clip),
in_r,
)
.to_command();
push_multi(vec![gap, rehome, place], "Move Clip to Track")
}
/// Delete `clip` leaving a gap (undoable "Delete Clips"; the facade's
/// single-clip `oakengine_sequence_delete_clips` composition).
pub fn delete_clip(p: &ProjectRef, clip: NodeId) -> Result<(), String> {
let source_track = {
let g = lock(p);
clip_track(&g.graph, clip).ok_or_else(|| "the clip is not on a track".to_string())?
};
let gap = oaktimeline::undogeneral::TrackReplaceBlockWithGapCommand::new(
node_ref(p, source_track),
node_ref(p, clip),
true,
)
.to_command();
let remove = oaktask::nodeops::remove_node_command(p.clone(), clip);
push_multi(vec![gap, remove], "Delete Clips")
}
/// Delete `clip` and ripple the following content left (undoable
/// "Ripple Delete Clip"; the module's `TrackRippleRemoveAreaCommand`
/// over the clip's range).
pub fn ripple_delete_clip(p: &ProjectRef, clip: NodeId) -> Result<(), String> {
let (track, range) = {
let g = lock(p);
let track =
clip_track(&g.graph, clip).ok_or_else(|| "the clip is not on a track".to_string())?;
let (in_r, out_r, _) =
clip_range(&g.graph, clip).ok_or_else(|| "the node is not a clip".to_string())?;
(track, TimeRange::new(in_r, out_r))
};
push(
oaktimeline::undoripple::TrackRippleRemoveAreaCommand::new(node_ref(p, track), range)
.to_command(),
"Ripple Delete Clip",
)
}
/// Undoable marker add ("Add Marker"). `time` is a rational seconds
/// in-point; a marker at the same time is rejected (the engine's marker
/// insertion asserts on duplicate times).
pub fn marker_add(markers: &CHandle, time: Rational, name: &str, color: i32) -> Result<(), String> {
if marker_index_at(markers, time).is_some() {
return Err("a marker already exists at that time".to_string());
}
push(
oaktimeline::marker::MarkerAddCommand::new(
*markers,
TimeRange::new(time, time),
name,
color,
)
.to_command(),
"Add Marker",
)
}
/// Undoable marker remove ("Remove Marker"); `None`-equivalent when no
/// marker sits at `time` (the caller treats it as a benign no-op).
pub fn marker_remove(markers: &CHandle, time: Rational) -> Result<(), String> {
let Some(index) = marker_index_at(markers, time) else {
return Err("no marker at that time".to_string());
};
push(
oaktimeline::marker::MarkerRemoveCommand::new(*markers, index).to_command(),
"Remove Marker",
)
}
/// Undoable workarea set ("Set Workarea"): the enabled flag plus the
/// in/out range as ONE entry. `old_range` is supplied by the caller (the
/// range read before the change — e.g. the drag-start range of a ruler
/// work-area drag); the enabled flag's previous value is captured by the
/// module command itself.
pub fn workarea_set_undoable(
wa: &CHandle,
enabled: bool,
range: TimeRange,
old_range: TimeRange,
) -> Result<(), String> {
push_multi(
vec![
oaktimeline::workarea::WorkareaSetEnabledCommand::new(*wa, enabled).to_command(),
oaktimeline::workarea::WorkareaSetRangeCommand::new_with_old(*wa, range, old_range)
.to_command(),
],
"Set Workarea",
)
}
/// Undoable removal of `node` from the project graph ("Remove Node"; the
/// module's remove command drops incident edges and restores the entry on
/// undo). The sequence node itself (the graph's output) cannot be
/// removed.
pub fn remove_node(p: &ProjectRef, node: NodeId) -> Result<(), String> {
push(
oaktimeline::undocommon::create_remove_command(&node_ref(p, node)),
"Remove Node",
)
}
// ---------------------------------------------------------------------------
// Test serialization
// ---------------------------------------------------------------------------
/// A process-wide test lock: the app's tests share the oakundo global
/// stack, the oakcommon config store and the codec decode sessions, so any
/// test touching them serializes on this lock.
#[cfg(test)]
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())
}
// ---------------------------------------------------------------------------
// Clipboard (Cut / Copy / Paste)
// ---------------------------------------------------------------------------
/// One clip captured in the clipboard (Cut/Copy). Everything needed to
/// re-place it on a timeline is here; effects/multicam contexts are not
/// modeled yet (footage clips carry their block core only).
#[derive(Clone, Debug)]
pub struct ClipboardClip {
/// The footage node the clip decodes.
pub footage: NodeId,
/// Track type the clip was on.
pub kind: TrackType,
/// Per-type index of the track it was on (re-used on paste when the
/// track still exists).
pub track_index: usize,
/// Media in-point in frame timestamps.
pub media_in_ts: i64,
/// Timeline in-point in frame timestamps.
pub start_ts: i64,
/// Duration in frame timestamps.
pub length_ts: i64,
/// Playback speed.
pub speed: f64,
}
/// Capture the selected clips into clipboard form (`Copy`; `Cut` copies
/// then deletes). Clips without a footage upstream are skipped.
pub fn copy_clips(p: &ProjectRef, clips: &[NodeId]) -> Vec<ClipboardClip> {
let g = lock(p);
let mut out = Vec::new();
for &clip in clips {
let Some(footage) = find_input_footage(&g.graph, clip) else {
continue;
};
let Some((in_r, out_r, media_in)) = clip_range(&g.graph, clip) else {
continue;
};
let Some(track) = clip_track(&g.graph, clip) else {
continue;
};
let Some(t) = track_behavior(&g.graph, track) else {
continue;
};
let Some(list) = t.track_list.and_then(|l| track_list_behavior(&g.graph, l)) else {
continue;
};
let (tb_num, tb_den) = {
// Frame timestamps are stored rationally; the clipboard keeps
// the sequence timebase for exactness.
let seq = list.sequence.and_then(|s| sequence_time_base(&g.graph, s));
seq.unwrap_or((1, 25))
};
let to_ts = |r: Rational| {
(r.numerator() * tb_den).checked_div(r.denominator() * tb_num).unwrap_or(0)
};
let speed = clip_behavior(&g.graph, clip)
.map(|c| c.core.speed)
.unwrap_or(1.0);
out.push(ClipboardClip {
footage,
kind: list.kind,
track_index: t.index.max(0) as usize,
media_in_ts: to_ts(media_in),
start_ts: to_ts(in_r),
length_ts: to_ts(out_r - in_r),
speed,
});
}
out.sort_by_key(|c| c.start_ts);
out
}
/// Paste clipboard clips at `playhead_ts` (frame timestamps), undoable
/// "Paste". The first clip's in-point moves to the playhead; the others
/// keep their relative offsets. Tracks are reused by kind+index (falling
/// back to the last track of the kind). Clips that were linked stay
/// linked inside the pasted group.
pub fn paste_clips(
p: &ProjectRef,
seq: NodeId,
items: &[ClipboardClip],
playhead_ts: i64,
) -> Result<Vec<NodeId>, String> {
if items.is_empty() {
return Err("the clipboard is empty".to_string());
}
let anchor = items[0].start_ts;
let mut clips = Vec::with_capacity(items.len());
for item in items {
let start = playhead_ts + (item.start_ts - anchor);
let out = start + item.length_ts;
// Re-use the placement machinery one clip at a time, collecting
// the commands so everything lands in ONE undo entry.
clips.push((item, start, out));
}
let tb = {
let g = lock(p);
sequence_time_base(&g.graph, seq)
.ok_or_else(|| "sequence has no valid frame rate".to_string())?
};
let track_count_of = |kind: TrackType| -> usize {
let g = lock(p);
track_list_of(&g.graph, seq, kind)
.and_then(|l| track_list_behavior(&g.graph, l))
.map(|l| l.tracks.len())
.unwrap_or(0)
};
let mut new_ids = Vec::with_capacity(items.len());
let mut commands = Vec::new();
for (item, start, out) in clips {
let list = {
let g = lock(p);
track_list_of(&g.graph, seq, item.kind)
.ok_or_else(|| "sequence has no track list for this type".to_string())?
};
let count = track_count_of(item.kind);
if count == 0 {
return Err("no track of the clip's kind to paste onto".to_string());
}
let track_index = item.track_index.min(count - 1);
let in_r = ts_to_rational(start, tb);
let media_r = ts_to_rational(item.media_in_ts, tb);
let length_r = ts_to_rational(out, tb) - in_r;
let clip = {
let mut g = lock(p);
let (core, behavior) = oaknode::block::clip_create();
let id = g.graph.add_node(core, behavior);
if let Some(c) = g
.graph
.get_mut(id)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
{
c.core.media_in = media_r;
c.core.speed = item.speed;
c.core.set_length_and_media_in(length_r);
}
id
};
new_ids.push(clip);
commands.push(
oaktimeline::undopointer::TrackPlaceBlockCommand::new(
node_ref(p, list),
track_index as i32,
node_ref(p, clip),
in_r,
)
.to_command(),
);
commands.push(connect_command(
p,
item.footage,
clip,
oaknode::block::clip_input::TEXTURE_INPUT,
)?);
}
// Link the pasted group both ways (the C++ pastes linked selections as
// a linked group).
if new_ids.len() > 1 {
let first = new_ids[0];
for &other in &new_ids[1..] {
let (p1, p2) = (p.clone(), p.clone());
commands.push(oakundo::undocommand::UndoCommand::from_closures(
move || {
let mut g = lock(&p1);
for (a, b) in [(first, other), (other, first)] {
if let Some(entry) = g.graph.get_mut(a) {
let links = &mut entry
.behavior
.as_any_mut()
.and_then(|any| any.downcast_mut::<ClipBlockBehavior>())
.expect("clip behavior")
.core
.links;
if !links.contains(&b) {
links.push(b);
}
}
}
},
move || {
let mut g = lock(&p2);
for (a, b) in [(first, other), (other, first)] {
if let Some(entry) = g.graph.get_mut(a) {
let links = &mut entry
.behavior
.as_any_mut()
.and_then(|any| any.downcast_mut::<ClipBlockBehavior>())
.expect("clip behavior")
.core
.links;
links.retain(|&l| l != b);
}
}
},
));
}
}
push_multi(commands, "Paste")?;
Ok(new_ids)
}
// ---------------------------------------------------------------------------
// 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();
}
/// Undo/redo stability: undo → redo → undo → redo must converge to the
/// SAME graph state every cycle (the user's "撤销再前进再撤销再前进,
/// 结果居然变了" regression). Snapshot the sequence's track blocks and
/// the graph node count around two full cycles.
#[test]
fn undo_redo_cycles_converge_to_the_same_state() {
let _g = test_lock();
oakundo::global::clear().unwrap();
let project = create_project();
let seq = create_sequence(&project, "Undo Cycles");
let media = std::env::temp_dir().join(format!("oak_undo_cycle_{}.mp4", std::process::id()));
oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media");
let footage = import_footage(&project, &media).expect("import");
place_footage_clips_linked(
&project,
seq,
footage,
&[(TrackType::Video, 0), (TrackType::Audio, 0)],
0,
10,
0,
)
.expect("linked placement");
let snapshot = |p: &ProjectRef| -> (usize, Vec<(TrackType, Vec<usize>)>) {
let g = lock(p);
let node_count = g.graph.node_count();
let mut tracks = Vec::new();
for kind in [TrackType::Video, TrackType::Audio] {
let blocks: Vec<usize> = track_ids(&g.graph, seq, kind)
.iter()
.map(|t| track_behavior(&g.graph, *t).map(|t| t.blocks.len()).unwrap_or(0))
.collect();
tracks.push((kind, blocks));
}
(node_count, tracks)
};
let before = snapshot(&project);
// Two full undo/redo cycles; the state must be identical after
// every redo and match the pre-undo state after every undo.
for cycle in 0..2 {
oakundo::global::undo().expect("undo");
let g = lock(&project);
let video_blocks: usize = track_ids(&g.graph, seq, TrackType::Video)
.iter()
.map(|t| track_behavior(&g.graph, *t).map(|t| t.blocks.len()).unwrap_or(0))
.sum();
assert_eq!(video_blocks, 0, "cycle {cycle}: undo removes the clips");
drop(g);
oakundo::global::redo().expect("redo");
let state = snapshot(&project);
assert_eq!(state, before, "cycle {cycle}: redo must restore the exact state");
}
oakundo::global::clear().unwrap();
let _ = std::fs::remove_file(&media);
}
/// 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();
}
}
#[cfg(test)]
mod undo_cycle_track_tests {
use super::*;
/// add_track undo/redo cycles must converge (the track count AND the
/// created track's identity stay stable; the C++ remove-last undo must
/// not eat a default track).
#[test]
fn add_track_undo_redo_cycles_converge() {
let _g = test_lock();
oakundo::global::clear().unwrap();
let project = create_project();
let seq = create_sequence(&project, "Add Track Cycles");
let count_of = |p: &ProjectRef, kind: TrackType| {
let g = lock(p);
track_ids(&g.graph, seq, kind).len()
};
assert_eq!(count_of(&project, TrackType::Video), 2, "default 2 video tracks");
let index = add_track(&project, seq, TrackType::Video).expect("add a track");
assert_eq!(index, 2, "the new track is the third video track");
let track_id = {
let g = lock(&project);
track_ids(&g.graph, seq, TrackType::Video)[index]
};
for cycle in 0..3 {
oakundo::global::undo().expect("undo");
assert_eq!(
count_of(&project, TrackType::Video),
2,
"cycle {cycle}: undo removes only the added track"
);
oakundo::global::redo().expect("redo");
assert_eq!(
count_of(&project, TrackType::Video),
3,
"cycle {cycle}: redo restores the added track"
);
let g = lock(&project);
assert!(
g.graph.is_valid(track_id),
"cycle {cycle}: the same track node is back in the graph"
);
assert_eq!(
track_ids(&g.graph, seq, TrackType::Video)[index],
track_id,
"cycle {cycle}: the track keeps its identity and position"
);
drop(g);
}
oakundo::global::clear().unwrap();
}
}
#[cfg(test)]
mod undo_cycle_ops_tests {
use super::*;
/// Full-graph snapshot for convergence checks: node count plus, for
/// every track, the ordered block ids and each block's range.
fn snapshot(p: &ProjectRef, seq: NodeId) -> (usize, Vec<(NodeId, Vec<(NodeId, i128, i128)>)>) {
let g = lock(p);
let mut tracks = Vec::new();
for kind in [TrackType::Video, TrackType::Audio] {
for t in track_ids(&g.graph, seq, kind) {
let blocks: Vec<(NodeId, i128, i128)> = track_behavior(&g.graph, t)
.map(|t| {
t.blocks
.iter()
.map(|&b| {
let (in_r, out_r, _) = clip_range(&g.graph, b).unwrap_or_default();
(
b,
in_r.numerator() as i128 * 1_000_000 / in_r.denominator().max(1) as i128,
out_r.numerator() as i128 * 1_000_000 / out_r.denominator().max(1) as i128,
)
})
.collect()
})
.unwrap_or_default();
tracks.push((t, blocks));
}
}
(g.graph.node_count(), tracks)
}
fn cycle_assert(p: &ProjectRef, seq: NodeId, post: &(usize, Vec<(NodeId, Vec<(NodeId, i128, i128)>)>), what: &str) {
for cycle in 0..3 {
oakundo::global::undo().unwrap_or_else(|e| panic!("{what}: undo failed: {e:?}"));
oakundo::global::redo().unwrap_or_else(|e| panic!("{what}: redo failed: {e:?}"));
let state = snapshot(p, seq);
assert_eq!(&state, post, "{what}: cycle {cycle} diverged");
}
}
fn project_with_two_clips(media: &std::path::Path) -> (ProjectRef, NodeId, NodeId) {
let project = create_project();
let seq = create_sequence(&project, "Cycle Ops");
let footage = import_footage(&project, media).expect("import");
place_footage_clips_linked(
&project,
seq,
footage,
&[(TrackType::Video, 0), (TrackType::Audio, 0)],
0,
10,
0,
)
.expect("linked placement")
.into_iter()
.next()
.map(|_| (project.clone(), seq, footage))
.expect("one clip")
}
/// Move / trim / delete / split undo-redo cycles must all converge.
#[test]
fn move_trim_delete_split_cycles_converge() {
let _g = test_lock();
oakundo::global::clear().unwrap();
let media = std::env::temp_dir().join(format!("oak_cycle_ops_{}.mp4", std::process::id()));
oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate");
// --- move ---
let (project, seq, _footage) = project_with_two_clips(&media);
let clip = {
let g = lock(&project);
track_ids(&g.graph, seq, TrackType::Video)
.iter()
.find_map(|t| track_behavior(&g.graph, *t).and_then(|t| t.blocks.first().copied()))
.expect("a clip")
};
move_clip(&project, clip, 20).expect("move");
let post = snapshot(&project, seq);
cycle_assert(&project, seq, &post, "move");
oakundo::global::clear().unwrap();
// --- trim ---
trim_clip(&project, clip, 22, 28).expect("trim");
let post = snapshot(&project, seq);
cycle_assert(&project, seq, &post, "trim");
oakundo::global::clear().unwrap();
// --- split ---
split_clip(&project, clip, 25).expect("split");
let post = snapshot(&project, seq);
cycle_assert(&project, seq, &post, "split");
oakundo::global::clear().unwrap();
// --- delete ---
delete_clip(&project, clip).expect("delete");
let post = snapshot(&project, seq);
cycle_assert(&project, seq, &post, "delete");
oakundo::global::clear().unwrap();
// --- ripple delete (on a fresh project, then check) ---
let (project, seq, _footage) = project_with_two_clips(&media);
let clip = {
let g = lock(&project);
track_ids(&g.graph, seq, TrackType::Video)
.iter()
.find_map(|t| track_behavior(&g.graph, *t).and_then(|t| t.blocks.first().copied()))
.expect("a clip")
};
ripple_delete_clip(&project, clip).expect("ripple delete");
let post = snapshot(&project, seq);
cycle_assert(&project, seq, &post, "ripple delete");
oakundo::global::clear().unwrap();
let _ = std::fs::remove_file(&media);
}
}