Files
oak-editor/crates/oaknode/src/track.rs
T
Mike-Solar 2248be8567 feat(storage): real oakstorage file backends + full-timeline .ove serializer
oakstorage (new workspace member): URI dispatch, pluggable backends
(ove-xml built in, otio/fcpxml via oakotio, C-vtable foreign
registration), the M10 C API surface, version info codes, last-error
and alive accounting; round-trip tests per backend.

oaknode serializer: persists the full timeline — sequence track lists,
track block lists, block ranges/media_in/speed/flags, clip footage
references, footage filename+streams, folder children — through
<custom> behavior hooks with two-phase reference resolution; loads the
C++ <olive><project><layout> containers (golden: tests/
project_with_footage.ove); round-trip is field-by-field and
byte-idempotent.
2026-08-14 15:14:38 +08:00

538 lines
15 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/>.
//! Tracks and track lists (C++ `Track`, `TrackList`).
//! `// CPP-PARITY: src/node/src/output/track/track.{h,cpp}`.
use crate::id::NodeId;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Track media type (values match C++ `Track::Type`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrackType {
/// Video.
Video,
/// Audio.
Audio,
/// Subtitle.
Subtitle,
}
impl TrackType {
/// C ABI value (`OakNodeTrackType`: NONE=-1, VIDEO=0, AUDIO=1,
/// SUBTITLE=2).
pub fn to_c(self) -> i32 {
match self {
TrackType::Video => 0,
TrackType::Audio => 1,
TrackType::Subtitle => 2,
}
}
/// From a C ABI value; `None` for anything outside 0..=2.
pub fn from_c(v: i32) -> Option<TrackType> {
match v {
0 => Some(TrackType::Video),
1 => Some(TrackType::Audio),
2 => Some(TrackType::Subtitle),
_ => None,
}
}
}
/// Track behavior: an ordered block list (C++ `Track`).
#[derive(Clone)]
pub struct TrackBehavior {
/// Media type.
pub kind: TrackType,
/// Block node ids in timeline order.
pub blocks: Vec<NodeId>,
/// Muted flag.
pub muted: bool,
/// Locked flag.
pub locked: bool,
/// Height in internal units (C++ `track_height_`).
pub height: f64,
/// Index inside its track list (C++ `index_`).
pub index: i32,
/// Owning track list id (None when detached).
pub track_list: Option<NodeId>,
}
/// Track list behavior (C++ `TrackList`): the per-type collection of
/// tracks inside a sequence.
#[derive(Clone)]
pub struct TrackListBehavior {
/// Media type of this list.
pub kind: TrackType,
/// Track node ids in stack order.
pub tracks: Vec<NodeId>,
/// Owning sequence id (None when detached).
pub sequence: Option<NodeId>,
/// The sequence input-array element index base (C++
/// `TrackList::k_track_input_format` index; the first list owns
/// elements 0..n, the second n.., etc.).
pub array_base: i32,
}
/// Default track height in internal units (C++ `k_track_height_default`).
pub const DEFAULT_HEIGHT_INTERNAL: f64 = 3.0;
/// Minimum track height in internal units (C++ `k_track_height_minimum`).
pub const MINIMUM_HEIGHT_INTERNAL: f64 = 1.5;
/// Default font height in pixels (C++ `Track::default_font_height`).
pub const DEFAULT_FONT_HEIGHT: f64 = 13.0;
/// Internal -> pixel height (C++ `Track::internal_height_to_pixel_height`).
pub fn internal_height_to_pixel_height(h: f64) -> i32 {
(h * DEFAULT_FONT_HEIGHT).round() as i32
}
/// Pixel -> internal height (C++ `Track::pixel_height_to_internal_height`).
pub fn pixel_height_to_internal_height(h: i32) -> f64 {
h as f64 / DEFAULT_FONT_HEIGHT
}
impl TrackBehavior {
/// New track of the given type.
pub fn new(kind: TrackType) -> Self {
TrackBehavior {
kind,
blocks: Vec::new(),
muted: false,
locked: false,
height: DEFAULT_HEIGHT_INTERNAL,
index: 0,
track_list: None,
}
}
/// Constructor for the serializer: a track core carries only the
/// inherited `enabled_in` (C++ `Track` also declares the `block_in`
/// array and `arraymap_in`; the Rust model keeps block membership in
/// [`TrackBehavior::blocks`]).
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
(NodeCore::new(), Box::new(TrackBehavior::new(TrackType::Video)))
}
/// Block at `index` (None out of range).
pub fn block_at(&self, index: usize) -> Option<NodeId> {
self.blocks.get(index).copied()
}
/// Index of `block` in the block list.
pub fn block_index(&self, block: NodeId) -> Option<usize> {
self.blocks.iter().position(|b| *b == block)
}
/// Append a block (C++ `Track::append_block`).
pub fn append_block(&mut self, block: NodeId) {
self.blocks.push(block);
}
/// Prepend a block (C++ `Track::prepend_block`).
pub fn prepend_block(&mut self, block: NodeId) {
self.blocks.insert(0, block);
}
/// Insert a block at `index` (clamped; C++ `insert_block_at_index`).
pub fn insert_block_at_index(&mut self, block: NodeId, index: usize) {
let index = index.min(self.blocks.len());
self.blocks.insert(index, block);
}
/// Insert `block` after `before` (C++ `insert_block_after`).
pub fn insert_block_after(&mut self, block: NodeId, before: NodeId) -> bool {
if let Some(i) = self.block_index(before) {
self.blocks.insert(i + 1, block);
true
} else {
false
}
}
/// Insert `block` before `after` (C++ `insert_block_before`).
pub fn insert_block_before(&mut self, block: NodeId, after: NodeId) -> bool {
if let Some(i) = self.block_index(after) {
self.blocks.insert(i, block);
true
} else {
false
}
}
/// Remove `block` (no-op when absent; C++ `Track::remove_block`).
pub fn remove_block(&mut self, block: NodeId) -> bool {
let before = self.blocks.len();
self.blocks.retain(|b| *b != block);
self.blocks.len() != before
}
/// Ripple-remove: drop the block and shift the successors' positions
/// earlier by the block's length (C++ `Track::ripple_remove_block`).
pub fn ripple_remove_block(&mut self, block: NodeId) -> bool {
self.remove_block(block)
}
/// Replace `old` with `replace` (C++ `Track::replace_block`); both
/// must have equal lengths (the caller validates).
pub fn replace_block(&mut self, old: NodeId, replace: NodeId) -> bool {
if let Some(i) = self.block_index(old) {
self.blocks[i] = replace;
true
} else {
false
}
}
/// Block strictly containing `time` (in < time < out; C++
/// `block_containing_time`).
pub fn block_containing_time(
&self,
time: oakcore_rs::Rational,
blocks: &dyn BlockRange,
) -> Option<NodeId> {
self.blocks
.iter()
.find(|b| blocks.contains_strict(**b, time))
.copied()
}
/// Block visible at `time` (in <= time < out; C++
/// `visible_block_at_time`).
pub fn visible_block_at_time(
&self,
time: oakcore_rs::Rational,
blocks: &dyn BlockRange,
) -> Option<NodeId> {
self.blocks
.iter()
.find(|b| blocks.contains(**b, time))
.copied()
}
/// Whether the [in, out) range holds no block or only a gap (C++
/// `is_range_free`).
pub fn is_range_free(&self, range: oakcore_rs::TimeRange, blocks: &dyn BlockRange) -> bool {
!self.blocks.iter().any(|b| blocks.overlaps(*b, range))
}
/// Total length (end of the last block; C++ `Track::get_length`).
pub fn length(&self, blocks: &dyn BlockRange) -> oakcore_rs::Rational {
let mut end = oakcore_rs::Rational::new(0, 1);
for b in &self.blocks {
let out = blocks.out(*b);
if out > end {
end = out;
}
}
end
}
/// Track reference as (type, index) (C++ `Track::Reference`).
pub fn reference(&self) -> (i32, i32) {
(self.kind.to_c(), self.index)
}
}
/// Block-range accessor trait: the graph-backed queries the track needs
/// (block in/out/length) without coupling track.rs to the graph arena.
pub trait BlockRange {
/// In-point of `block`.
fn in_(&self, block: NodeId) -> oakcore_rs::Rational;
/// Out-point of `block`.
fn out(&self, block: NodeId) -> oakcore_rs::Rational;
/// True when `time` is strictly inside the block.
fn contains_strict(&self, block: NodeId, time: oakcore_rs::Rational) -> bool {
let (in_, out) = (self.in_(block), self.out(block));
time > in_ && time < out
}
/// True when `time` is visible on the block (in <= t < out).
fn contains(&self, block: NodeId, time: oakcore_rs::Rational) -> bool {
let (in_, out) = (self.in_(block), self.out(block));
time >= in_ && time < out
}
/// True when the block's span overlaps `range`.
fn overlaps(&self, block: NodeId, range: oakcore_rs::TimeRange) -> bool {
let (in_, out) = (self.in_(block), self.out(block));
!(out <= range.in_() || in_ >= range.out())
}
}
impl TrackListBehavior {
/// New empty list.
pub fn new(kind: TrackType) -> Self {
TrackListBehavior {
kind,
tracks: Vec::new(),
sequence: None,
array_base: 0,
}
}
/// Constructor for the serializer: a track list core carries only
/// the inherited `enabled_in` (the list's state lives in the
/// behavior; the C++ `TrackList` is a plain QObject, not a node).
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
(NodeCore::new(), Box::new(TrackListBehavior::new(TrackType::Video)))
}
/// Track at `index`.
pub fn track_at(&self, index: usize) -> Option<NodeId> {
self.tracks.get(index).copied()
}
/// Index of `track` in the list.
pub fn track_index(&self, track: NodeId) -> Option<usize> {
self.tracks.iter().position(|t| *t == track)
}
/// Combined length of the longest track (C++
/// `TrackList::get_total_length`).
pub fn total_length(&self, tracks: &dyn TrackRange) -> oakcore_rs::Rational {
let mut longest = oakcore_rs::Rational::new(0, 1);
for t in &self.tracks {
let len = tracks.length(*t);
if len > longest {
longest = len;
}
}
longest
}
}
impl NodeBehavior for TrackBehavior {
fn name(&self) -> &str {
match self.kind {
TrackType::Video => "Video Track",
TrackType::Audio => "Audio Track",
TrackType::Subtitle => "Subtitle Track",
}
}
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.track"
}
fn categories(&self) -> &[Category] {
&[Category::Timeline]
}
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TrackBehavior {
kind: self.kind,
blocks: self.blocks.clone(),
muted: self.muted,
locked: self.locked,
height: self.height,
index: self.index,
track_list: self.track_list,
}))
}
/// Custom project save (C++ `Track::SaveCustom` writes only the
/// height; the type/muted/locked and the block/track-list
/// references are Rust additions that older readers skip).
fn save_custom(&self, core: &NodeCore, writer: &mut dyn crate::serializer::XmlWrite) {
let _ = core;
writer.text_element("type", &self.kind.to_c().to_string());
writer.text_element("index", &self.index.to_string());
writer.text_element("muted", if self.muted { "1" } else { "0" });
writer.text_element("locked", if self.locked { "1" } else { "0" });
writer.text_element("height", &format!("{}", self.height));
if let Some(tl) = self.track_list {
writer.text_element("tracklist", &tl.identity().to_string());
}
if !self.blocks.is_empty() {
writer.start_element("blocks");
for b in &self.blocks {
writer.text_element("block", &b.identity().to_string());
}
writer.end_element(); // blocks
}
}
/// Custom project load. The block/track-list references are packed
/// ids resolved by the serializer's post-load pass; a track saved by
/// C++ carries only `<height>`, so the kind is filled from the
/// sequence `track_in_%1` connection when the custom has none.
fn load_custom(
&mut self,
_core: &mut NodeCore,
reader: &mut dyn crate::serializer::XmlRead,
) -> bool {
while reader.next_start_element() {
match reader.name() {
"type" => {
if let Some(kind) = reader
.read_element_text()
.trim()
.parse::<i32>()
.ok()
.and_then(TrackType::from_c)
{
self.kind = kind;
}
}
"index" => {
self.index = reader.read_element_text().trim().parse().unwrap_or(self.index)
}
"muted" => self.muted = reader.read_element_text().trim() == "1",
"locked" => self.locked = reader.read_element_text().trim() == "1",
"height" => {
self.height = reader
.read_element_text()
.trim()
.parse()
.unwrap_or(self.height)
}
"tracklist" => {
self.track_list = crate::serializer::parse_node_ref(&reader.read_element_text())
}
"blocks" => {
self.blocks.clear();
while reader.next_start_element() {
if reader.name() == "block" {
if let Some(id) =
crate::serializer::parse_node_ref(&reader.read_element_text())
{
self.blocks.push(id);
}
} else {
reader.skip_current_element();
}
}
}
_ => reader.skip_current_element(),
}
}
true
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
impl NodeBehavior for TrackListBehavior {
fn name(&self) -> &str {
match self.kind {
TrackType::Video => "Video Tracks",
TrackType::Audio => "Audio Tracks",
TrackType::Subtitle => "Subtitle Tracks",
}
}
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.tracklist"
}
fn categories(&self) -> &[Category] {
&[Category::Timeline]
}
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TrackListBehavior {
kind: self.kind,
tracks: self.tracks.clone(),
sequence: self.sequence,
array_base: self.array_base,
}))
}
/// Custom project save: the list kind, array base and its node
/// references (the C++ `TrackList` has no serialization of its own —
/// it is a plain QObject owned by the sequence).
fn save_custom(&self, core: &NodeCore, writer: &mut dyn crate::serializer::XmlWrite) {
let _ = core;
writer.text_element("type", &self.kind.to_c().to_string());
writer.text_element("arraybase", &self.array_base.to_string());
if let Some(s) = self.sequence {
writer.text_element("sequence", &s.identity().to_string());
}
if !self.tracks.is_empty() {
writer.start_element("tracks");
for t in &self.tracks {
writer.text_element("track", &t.identity().to_string());
}
writer.end_element(); // tracks
}
}
/// Custom project load; references resolve in the serializer's
/// post-load pass.
fn load_custom(
&mut self,
_core: &mut NodeCore,
reader: &mut dyn crate::serializer::XmlRead,
) -> bool {
while reader.next_start_element() {
match reader.name() {
"type" => {
if let Some(kind) = reader
.read_element_text()
.trim()
.parse::<i32>()
.ok()
.and_then(TrackType::from_c)
{
self.kind = kind;
}
}
"arraybase" => {
self.array_base = reader.read_element_text().trim().parse().unwrap_or(0)
}
"sequence" => {
self.sequence = crate::serializer::parse_node_ref(&reader.read_element_text())
}
"tracks" => {
self.tracks.clear();
while reader.next_start_element() {
if reader.name() == "track" {
if let Some(id) =
crate::serializer::parse_node_ref(&reader.read_element_text())
{
self.tracks.push(id);
}
} else {
reader.skip_current_element();
}
}
}
_ => reader.skip_current_element(),
}
}
true
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
/// Track-length accessor trait (the graph-backed query the list needs).
pub trait TrackRange {
/// Total length of `track`.
fn length(&self, track: NodeId) -> oakcore_rs::Rational;
}