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.
This commit is contained in:
2026-08-14 15:14:38 +08:00
parent 48d3027d50
commit 2248be8567
26 changed files with 5755 additions and 231 deletions
+147
View File
@@ -162,6 +162,77 @@ pub mod transition_input {
pub const IN_BLOCK: &str = "in_block_in";
}
/// Save the shared [`BlockCore`] custom fields (C++ persists the
/// timeline span through the `length_in` input and the track's block
/// order; the Rust model owns the range directly, so the custom
/// segment carries it — new elements old readers skip).
fn save_block_core(writer: &mut dyn crate::serializer::XmlWrite, core: &BlockCore) {
writer.start_element("range");
writer.attribute("in", &core.in_().to_display_string());
writer.attribute("out", &core.out().to_display_string());
writer.end_element(); // range
writer.text_element("media_in", &core.media_in.to_display_string());
writer.text_element("speed", &format!("{}", core.speed));
writer.text_element("reversed", if core.reversed { "1" } else { "0" });
writer.text_element("enabled", if core.enabled { "1" } else { "0" });
writer.text_element(
"maintain_audio_pitch",
if core.maintain_audio_pitch { "1" } else { "0" },
);
writer.text_element("loop_mode", &core.loop_mode.to_string());
if let Some(t) = core.track {
writer.text_element("track", &t.identity().to_string());
}
}
/// Parse one block custom element. Elements owned by the block core are
/// applied to `core`; everything else is handed to `extra` so subclass
/// state (clip footage, transition offsets) can hook in.
fn load_block_core(
reader: &mut dyn crate::serializer::XmlRead,
core: &mut BlockCore,
extra: &mut dyn FnMut(&str, &mut dyn crate::serializer::XmlRead) -> bool,
) {
while reader.next_start_element() {
let name = reader.name().to_string();
match name.as_str() {
"range" => {
let in_ = reader
.attribute("in")
.map(|t| Rational::from_string(&t))
.unwrap_or_else(|| core.in_());
let out = reader
.attribute("out")
.map(|t| Rational::from_string(&t))
.unwrap_or_else(|| core.out());
core.range = TimeRange::new(in_, out);
// Consume the element (self-closing `<range/>` emits an
// EndElement token that the element loop must not treat
// as its own terminator).
let _ = reader.read_element_text();
}
"media_in" => core.media_in = Rational::from_string(&reader.read_element_text()),
"speed" => {
core.speed = reader.read_element_text().trim().parse().unwrap_or(core.speed)
}
"reversed" => core.reversed = reader.read_element_text().trim() == "1",
"enabled" => core.enabled = reader.read_element_text().trim() != "0",
"maintain_audio_pitch" => {
core.maintain_audio_pitch = reader.read_element_text().trim() == "1"
}
"loop_mode" => {
core.loop_mode = reader.read_element_text().trim().parse().unwrap_or(core.loop_mode)
}
"track" => core.track = crate::serializer::parse_node_ref(&reader.read_element_text()),
_ => {
if !extra(&name, reader) {
reader.skip_current_element();
}
}
}
}
}
impl ClipBlockBehavior {
/// New clip with a default length of one second.
pub fn new() -> Self {
@@ -230,6 +301,35 @@ impl NodeBehavior for ClipBlockBehavior {
footage: self.footage,
}))
}
/// Custom project save: the shared block span/state plus the
/// footage reference (C++ persists the span through inputs; the Rust
/// block owns it in [`BlockCore`]).
fn save_custom(&self, core: &NodeCore, writer: &mut dyn crate::serializer::XmlWrite) {
let _ = core;
save_block_core(writer, &self.core);
if let Some(f) = self.footage {
writer.text_element("footage", &f.identity().to_string());
}
}
/// Custom project load; the footage/track references resolve in the
/// serializer's post-load pass.
fn load_custom(
&mut self,
_core: &mut NodeCore,
reader: &mut dyn crate::serializer::XmlRead,
) -> bool {
let footage = &mut self.footage;
load_block_core(reader, &mut self.core, &mut |name, reader| match name {
"footage" => {
*footage = crate::serializer::parse_node_ref(&reader.read_element_text());
true
}
_ => false,
});
true
}
}
impl NodeBehavior for GapBlockBehavior {
@@ -258,6 +358,23 @@ impl NodeBehavior for GapBlockBehavior {
core: self.core.clone(),
}))
}
/// Custom project save: the shared block span/state only.
fn save_custom(&self, core: &NodeCore, writer: &mut dyn crate::serializer::XmlWrite) {
let _ = core;
save_block_core(writer, &self.core);
}
/// Custom project load; the track reference resolves in the
/// serializer's post-load pass.
fn load_custom(
&mut self,
_core: &mut NodeCore,
reader: &mut dyn crate::serializer::XmlRead,
) -> bool {
load_block_core(reader, &mut self.core, &mut |_, _| false);
true
}
}
impl NodeBehavior for TransitionBlockBehavior {
@@ -288,6 +405,36 @@ impl NodeBehavior for TransitionBlockBehavior {
out_offset: self.out_offset,
}))
}
/// Custom project save: the shared block span/state plus the
/// transition offsets.
fn save_custom(&self, core: &NodeCore, writer: &mut dyn crate::serializer::XmlWrite) {
let _ = core;
save_block_core(writer, &self.core);
writer.text_element("in_offset", &self.in_offset.to_display_string());
writer.text_element("out_offset", &self.out_offset.to_display_string());
}
/// Custom project load; the track reference resolves in the
/// serializer's post-load pass.
fn load_custom(
&mut self,
_core: &mut NodeCore,
reader: &mut dyn crate::serializer::XmlRead,
) -> bool {
load_block_core(reader, &mut self.core, &mut |name, reader| match name {
"in_offset" => {
self.in_offset = Rational::from_string(&reader.read_element_text());
true
}
"out_offset" => {
self.out_offset = Rational::from_string(&reader.read_element_text());
true
}
_ => false,
});
true
}
}
/// Constructor for a clip block (C++ `ClipBlock::ClipBlock()`): adds the
+8 -1
View File
@@ -9667,7 +9667,14 @@ pub mod serializer {
.filter(|(_, to, _, _)| *to == id)
.map(|(from, _, input, element)| (from, input, element))
.collect();
crate::serializer::save_node(&mut writer, &entry.core, id, &type_id, &connections)?;
crate::serializer::save_node(
&mut writer,
&entry.core,
&*entry.behavior,
id,
&type_id,
&connections,
)?;
// Serialize per-node properties as text elements.
let mut props: Vec<(&String, &String)> = sd
.properties
+57 -3
View File
@@ -111,12 +111,66 @@ impl NodeBehavior for FolderBehavior {
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(FolderBehavior::new(&self.name)))
}
/// Custom project save: the bin children (C++ attaches children
/// through the `child_in` input connections; the Rust model keeps
/// them in [`FolderBehavior::children`], so the custom segment is
/// the persistence channel — old readers skip it).
fn save_custom(&self, core: &NodeCore, writer: &mut dyn crate::serializer::XmlWrite) {
let _ = core;
if !self.children.is_empty() {
writer.start_element("children");
for c in &self.children {
writer.text_element("child", &c.identity().to_string());
}
writer.end_element(); // children
}
}
/// Custom project load; the child references resolve in the
/// serializer's post-load pass (which also folds in `child_in`
/// connections from C++ files).
fn load_custom(
&mut self,
_core: &mut NodeCore,
reader: &mut dyn crate::serializer::XmlRead,
) -> bool {
while reader.next_start_element() {
match reader.name() {
"children" => {
self.children.clear();
while reader.next_start_element() {
if reader.name() == "child" {
if let Some(id) =
crate::serializer::parse_node_ref(&reader.read_element_text())
{
self.children.push(id);
}
} else {
reader.skip_current_element();
}
}
}
_ => reader.skip_current_element(),
}
}
true
}
}
/// Constructor (C++ `Folder::Folder()`): a folder node has no inputs.
/// Constructor (C++ `Folder::Folder()`): a folder node declares the
/// `child_in` array input (C++ attaches bin children through it) but no
/// `enabled_in` (`// CPP-PARITY: folder.cpp:26`).
pub fn create(name: &str) -> (NodeCore, Box<dyn NodeBehavior>) {
// Folders carry no `enabled_in` in C++; keep the bare core.
(NodeCore::empty(), Box::new(FolderBehavior::new(name)))
let mut core = NodeCore::empty();
let mut child = crate::input::Input::new(
"child_in",
crate::value::ValueType::None,
crate::value::NodeValue::None,
);
child.flags |= crate::input::flags::ARRAY | crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(child);
(core, Box::new(FolderBehavior::new(name)))
}
/// Register a folder-typed node (used by the serializer for bin folders;
+198 -1
View File
@@ -21,8 +21,9 @@
use std::sync::atomic::{AtomicBool, Ordering};
use crate::input::Input;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{AudioParams, VideoParams};
use crate::value::{AudioParams, NodeValue, ValueType, VideoParams};
/// One media stream inside a footage file.
#[derive(Clone, Debug)]
@@ -208,6 +209,33 @@ impl FootageBehavior {
self.proxy_preset_version = 0;
self.proxy_enabled = false;
}
/// Constructor for the serializer: the C++ `Footage` input surface
/// (`file_in` + the viewer parameter stream arrays) with an unprobed
/// behavior (`// CPP-PARITY: footage.cpp:83`, `viewer.cpp:84`).
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut file = Input::new(
"file_in",
ValueType::Text,
NodeValue::Text(String::new()),
);
file.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(file);
for (id, ty) in [
("video_param_in", ValueType::VideoParams),
("audio_param_in", ValueType::AudioParams),
("subtitle_param_in", ValueType::None),
] {
let mut input = Input::new(id, ty, NodeValue::None);
input.flags |= crate::input::flags::NOT_CONNECTABLE
| crate::input::flags::NOT_KEYFRAMABLE
| crate::input::flags::ARRAY
| crate::input::flags::HIDDEN;
core.add_input(input);
}
(core, Box::new(FootageBehavior::new("")))
}
}
impl NodeBehavior for FootageBehavior {
@@ -239,6 +267,175 @@ impl NodeBehavior for FootageBehavior {
}))
}
/// Custom project save (C++ `Footage::SaveCustom`): the file name,
/// media timestamp, proxy state and the probed streams. `<filename>`
/// and `<streams>` are Rust additions (C++ reads the name from the
/// `file_in` input); old readers skip them.
fn save_custom(&self, core: &NodeCore, writer: &mut dyn crate::serializer::XmlWrite) {
let _ = core;
if !self.filename.is_empty() {
writer.text_element("filename", &self.filename);
}
if self.timestamp != 0 {
writer.text_element("timestamp", &self.timestamp.to_string());
}
if !self.proxy.is_empty() || self.proxy_enabled {
writer.start_element("proxy");
writer.attribute("enabled", if self.proxy_enabled { "1" } else { "0" });
writer.attribute("state", &self.proxy_state.to_string());
writer.attribute("stream", &self.proxy_video_stream_index.to_string());
writer.attribute("preset", &self.proxy_preset_version.to_string());
writer.characters(&self.proxy);
writer.end_element(); // proxy
}
if !self.streams.is_empty() {
writer.start_element("streams");
for s in &self.streams {
writer.start_element("stream");
writer.attribute("index", &s.index.to_string());
writer.attribute("video", if s.is_video { "1" } else { "0" });
writer.attribute("duration", &s.duration.to_display_string());
if let Some(v) = s.video {
writer.start_element("video");
writer.attribute("width", &v.width.to_string());
writer.attribute("height", &v.height.to_string());
writer.attribute("framerate", &v.frame_rate.to_display_string());
writer.attribute("pixelformat", &v.pixel_format.to_string());
writer.attribute("channels", &v.channels.to_string());
writer.end_element(); // video
}
if let Some(a) = s.audio {
writer.start_element("audio");
writer.attribute("samplerate", &a.sample_rate.to_string());
writer.attribute("channellayout", &a.channel_layout.to_string());
writer.attribute("format", &a.format.to_string());
writer.end_element(); // audio
}
writer.end_element(); // stream
}
writer.end_element(); // streams
}
}
/// Custom project load. C++ segments without a Rust counterpart
/// (`sourcestarttime`, `viewer` workarea/markers) are skipped.
fn load_custom(
&mut self,
_core: &mut NodeCore,
reader: &mut dyn crate::serializer::XmlRead,
) -> bool {
while reader.next_start_element() {
match reader.name() {
"filename" => self.filename = reader.read_element_text(),
"timestamp" => {
self.timestamp = reader.read_element_text().trim().parse().unwrap_or(0)
}
"proxy" => {
self.proxy_enabled = reader
.attribute("enabled")
.map(|v| v == "1")
.unwrap_or(false);
self.proxy_state = reader
.attribute("state")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
self.proxy_video_stream_index = reader
.attribute("stream")
.and_then(|v| v.parse().ok())
.unwrap_or(-1);
self.proxy_preset_version = reader
.attribute("preset")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
self.proxy = reader.read_element_text();
}
"streams" => {
self.streams.clear();
while reader.next_start_element() {
if reader.name() == "stream" {
let index = reader
.attribute("index")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let is_video = reader
.attribute("video")
.map(|v| v == "1")
.unwrap_or(false);
let duration = reader
.attribute("duration")
.map(|v| oakcore_rs::Rational::from_string(&v))
.unwrap_or_else(|| oakcore_rs::Rational::new(0, 1));
let mut video = None;
let mut audio = None;
while reader.next_start_element() {
match reader.name() {
"video" => {
video = Some(VideoParams {
width: reader
.attribute("width")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
height: reader
.attribute("height")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
frame_rate: reader
.attribute("framerate")
.map(|v| oakcore_rs::Rational::from_string(&v))
.unwrap_or_else(|| {
oakcore_rs::Rational::new(0, 1)
}),
pixel_format: reader
.attribute("pixelformat")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
channels: reader
.attribute("channels")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
});
// Consume the (self-closing) element.
let _ = reader.read_element_text();
}
"audio" => {
audio = Some(AudioParams {
sample_rate: reader
.attribute("samplerate")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
channel_layout: reader
.attribute("channellayout")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
format: reader
.attribute("format")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
});
// Consume the (self-closing) element.
let _ = reader.read_element_text();
}
_ => reader.skip_current_element(),
}
}
self.streams.push(StreamInfo {
index,
is_video,
video,
audio,
duration,
});
} else {
reader.skip_current_element();
}
}
}
_ => reader.skip_current_element(),
}
}
true
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
+5 -1
View File
@@ -677,7 +677,11 @@ pub trait NodeBehavior: Send {
core: &mut NodeCore,
reader: &mut dyn crate::serializer::XmlRead,
) -> bool {
let _ = (core, reader);
let _ = core;
// The default has no custom state; consume the segment so the
// node-body parser continues at the correct depth (the reader is
// positioned on the `<custom>` start element).
reader.skip_current_element();
true
}
+94 -1
View File
@@ -20,8 +20,9 @@
//! `// CPP-PARITY: src/node/src/project/sequence/sequence.{h,cpp}`.
use crate::id::NodeId;
use crate::input::Input;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{AudioParams, VideoParams};
use crate::value::{AudioParams, NodeValue, ValueType, VideoParams};
/// Sequence texture/samples input ids (ViewerOutput::k_texture_input /
/// k_samples_input) and the track input id format (Sequence::
@@ -68,6 +69,53 @@ impl SequenceBehavior {
}
}
/// Constructor: the C++ `Sequence` input surface with default
/// parameters but no track lists (`// CPP-PARITY: sequence.cpp:36`,
/// `viewer.cpp:84`). Used by the serializer to rebuild a sequence
/// from a file — the track lists arrive as separate nodes.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// Viewer parameter streams (C++ ViewerOutput::kVideoParamsInput /
// kAudioParamsInput / kSubtitleParamsInput arrays).
for (id, ty) in [
("video_param_in", ValueType::VideoParams),
("audio_param_in", ValueType::AudioParams),
("subtitle_param_in", ValueType::None),
] {
let mut input = Input::new(id, ty, NodeValue::None);
input.flags |= crate::input::flags::NOT_CONNECTABLE
| crate::input::flags::NOT_KEYFRAMABLE
| crate::input::flags::ARRAY
| crate::input::flags::HIDDEN;
core.add_input(input);
}
core.add_input(Input::new(
TEXTURE_INPUT,
ValueType::Texture,
NodeValue::None,
));
core.add_input(Input::new(
SAMPLES_INPUT,
ValueType::Samples,
NodeValue::None,
));
// One array input per track list (`track_in_%1`; the video list
// owns track_in_0, audio track_in_1, subtitle track_in_2 —
// `// CPP-PARITY: sequence.h`).
for base in 0..3 {
let mut track_input = Input::new(
&TRACK_INPUT_FORMAT.replace("%1", &base.to_string()),
ValueType::None,
NodeValue::None,
);
track_input.flags |= crate::input::flags::ARRAY;
core.add_input(track_input);
}
let mut behavior = SequenceBehavior::new();
behavior.set_default_parameters();
(core, Box::new(behavior))
}
/// Apply the default video/audio parameters (C++
/// `ViewerOutput::set_default_parameters()`; the config lookups use
/// oakcommon's defaults when the config module is absent).
@@ -141,6 +189,51 @@ impl NodeBehavior for SequenceBehavior {
Some(Box::new(SequenceBehavior::new()))
}
/// Custom project save (C++ `Sequence::SaveCustom` writes the
/// workarea and markers; those live behind opaque oaktimeline
/// handles in Rust, so the track-list references are all that
/// persists).
fn save_custom(&self, core: &NodeCore, writer: &mut dyn crate::serializer::XmlWrite) {
let _ = core;
if !self.track_lists.is_empty() {
writer.start_element("tracklists");
for t in &self.track_lists {
writer.text_element("tracklist", &t.identity().to_string());
}
writer.end_element(); // tracklists
}
}
/// Custom project load (C++ `Sequence::LoadCustom`): the track-list
/// references are collected here and resolved to live ids by the
/// serializer's post-load pass; the C++ workarea/markers segments
/// are skipped (opaque handles).
fn load_custom(
&mut self,
_core: &mut NodeCore,
reader: &mut dyn crate::serializer::XmlRead,
) -> bool {
while reader.next_start_element() {
match reader.name() {
"tracklists" => {
while reader.next_start_element() {
if reader.name() == "tracklist" {
if let Some(id) =
crate::serializer::parse_node_ref(&reader.read_element_text())
{
self.track_lists.push(id);
}
} else {
reader.skip_current_element();
}
}
}
_ => reader.skip_current_element(),
}
}
true
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
+267 -16
View File
@@ -278,6 +278,15 @@ pub fn interpolation_from_c(t: i32) -> Interpolation {
}
}
/// Parse a serialized node identity (a `<node>` `ptr` value or a
/// timeline reference) into a packed [`NodeId`]. The id is not resolved
/// to a live graph slot until the serializer's post-load pass maps it
/// through the id table (the packing only survives Rust-written files;
/// C++ `ptr` values are arbitrary addresses).
pub fn parse_node_ref(text: &str) -> Option<NodeId> {
text.trim().parse::<u64>().ok().and_then(NodeId::from_identity)
}
/// Save a whole project to the current-version XML format.
pub fn save(project: &Project) -> crate::error::Result<String> {
use crate::error::Error;
@@ -303,7 +312,7 @@ pub fn save(project: &Project) -> crate::error::Result<String> {
.filter(|(_, to, _, _)| *to == id)
.map(|(from, _, input, element)| (from, input, element))
.collect();
save_node(&mut writer, &entry.core, id, &type_id, &connections)?;
save_node(&mut writer, &entry.core, &*entry.behavior, id, &type_id, &connections)?;
writer.end_element(); // node
}
writer.end_element(); // nodes
@@ -321,10 +330,12 @@ pub fn save(project: &Project) -> crate::error::Result<String> {
}
/// Save one node (C++ `Node::save`). `connections` lists the node's
/// input connections `(source, input_id, element)`.
/// input connections `(source, input_id, element)`. The per-type custom
/// segment is written through [`NodeBehavior::save_custom`].
pub fn save_node(
writer: &mut dyn XmlWrite,
core: &NodeCore,
behavior: &dyn crate::node::NodeBehavior,
id: NodeId,
type_id: &str,
connections: &[(NodeId, String, i32)],
@@ -333,8 +344,25 @@ pub fn save_node(
writer.attribute("id", type_id);
writer.attribute("ptr", &id.identity().to_string());
if !core.label.is_empty() {
writer.text_element("label", &core.label);
// Folders persist their display name through the label (C++
// `Folder::Name()` returns the label; the Rust model keeps the name
// in the behavior, so fall back to it when the label is empty).
let label = if !core.label.is_empty() {
Some(core.label.as_str())
} else if let Some(f) = behavior
.as_any()
.and_then(|a| a.downcast_ref::<crate::folder::FolderBehavior>())
{
if f.name.is_empty() {
None
} else {
Some(f.name.as_str())
}
} else {
None
};
if let Some(label) = label {
writer.text_element("label", label);
}
if core.override_color != -1 {
writer.text_element("color", &core.override_color.to_string());
@@ -375,6 +403,7 @@ pub fn save_node(
writer.end_element(); // caches
writer.start_element("custom");
behavior.save_custom(core, writer);
writer.end_element(); // custom
Ok(())
@@ -507,7 +536,11 @@ pub fn load(xml: &str) -> crate::error::Result<Arc<Mutex<Project>>> {
Ok(project)
}
/// Parse the `<project>` body: uuid, nodes, settings.
/// Parse the `<project>` body: uuid, nodes, settings. C++ full saves
/// wrap the data in a container (`<olive><project><project version="1">
/// ...</project><layout>...</layout></project>` — `// CPP-PARITY:
/// serializer230220.cpp`); a nested `<project>` element is descended
/// into transparently instead of skipped.
fn load_project_body(reader: &mut dyn XmlRead, project: &mut Project) -> crate::error::Result<()> {
use crate::error::Error;
// Identity -> NodeId map for connection resolution.
@@ -517,8 +550,14 @@ fn load_project_body(reader: &mut dyn XmlRead, project: &mut Project) -> crate::
// Deferred links: (identity_a, identity_b).
let mut links: Vec<(u64, u64)> = Vec::new();
while reader.next_start_element() {
loop {
if !reader.next_start_element() {
break;
}
match reader.name() {
// The C++ full-save container: descend and parse its children
// as the project body.
"project" => {}
"uuid" => {
project.uuid = reader.read_element_text();
}
@@ -579,6 +618,13 @@ fn load_project_body(reader: &mut dyn XmlRead, project: &mut Project) -> crate::
}
}
// Rebuild the timeline structure: the custom segments carry packed
// references that only resolve now that every node is live.
resolve_timeline_refs(&mut project.graph, &id_map);
// Fold C++ `child_in` connections into the folder children and
// reattach each child to its bin folder.
resolve_folder_children(&mut project.graph, &id_map);
Ok(())
}
@@ -598,20 +644,20 @@ fn load_node(
.and_then(|p| p.parse::<u64>().ok())
.unwrap_or(0);
// Instantiate the node type; folders and unknown types fall back to
// an empty folder-ish core.
// Instantiate the node type; timeline structural types (which are
// not in the factory menu) are reconstructed directly, unknown
// types fall back to an error.
let (mut core, behavior): (NodeCore, Box<dyn crate::node::NodeBehavior>) =
if type_id == "org.olivevideoeditor.Olive.folder" {
crate::folder::create("Folder")
} else {
match crate::factory::Factory::global().find(&type_id) {
match create_timeline_type(&type_id) {
Some(x) => x,
None => match crate::factory::Factory::global().find(&type_id) {
Some(meta) => (meta.create)(),
None => {
// Unknown type: skip the element body.
reader.skip_current_element();
return Err(Error::Failed(format!("unknown node type '{}'", type_id)));
}
}
},
};
// The node enters the graph before its body is parsed so deferred
@@ -621,17 +667,45 @@ fn load_node(
id_map.insert(ptr, id);
}
// Parse the node body (into the entry's core).
// Parse the node body (into the entry's core and behavior).
let entry = graph.get_mut(id).ok_or(Error::NotFound)?;
load_node_body(reader, &mut entry.core, id, connections, links)?;
load_node_body(reader, &mut entry.core, &mut *entry.behavior, id, connections, links)?;
Ok(id)
}
/// Reconstruct a timeline structural node type for loading. These types
/// are absent from the factory menu (the app creates them through
/// dedicated APIs, C++ `factory.cpp` lists only user-creatable nodes);
/// the serializer instantiates them directly, following the existing
/// folder special case.
fn create_timeline_type(
type_id: &str,
) -> Option<(NodeCore, Box<dyn crate::node::NodeBehavior>)> {
match type_id {
"org.olivevideoeditor.Olive.folder" => Some(crate::folder::create("Folder")),
"org.olivevideoeditor.Olive.footage" => Some(crate::footage::FootageBehavior::create()),
"org.olivevideoeditor.Olive.sequence" => {
Some(crate::sequence::SequenceBehavior::create())
}
"org.olivevideoeditor.Olive.tracklist" => {
Some(crate::track::TrackListBehavior::create())
}
"org.olivevideoeditor.Olive.track" => Some(crate::track::TrackBehavior::create()),
"org.olivevideoeditor.Olive.clipblock" => Some(crate::block::clip_create()),
"org.olivevideoeditor.Olive.gapblock" => Some(crate::block::gap_create()),
"org.olivevideoeditor.Olive.transitionblock" => {
Some(crate::block::transition_create())
}
_ => None,
}
}
/// Parse the body of a `<node>` element (label/color/inputs/links/...).
fn load_node_body(
reader: &mut dyn XmlRead,
core: &mut NodeCore,
behavior: &mut dyn crate::node::NodeBehavior,
node_id: NodeId,
connections: &mut Vec<(u64, NodeId, String, i32)>,
links: &mut Vec<(u64, u64)>,
@@ -679,7 +753,12 @@ fn load_node_body(
}
}
}
"caches" | "custom" => reader.skip_current_element(),
"caches" => reader.skip_current_element(),
"custom" => {
// The reader is positioned at `<custom>`; the behavior
// parses its own segment (the default no-op skips it).
behavior.load_custom(core, reader);
}
_ => reader.skip_current_element(),
}
}
@@ -814,6 +893,178 @@ fn load_immediate(
}
}
/// Resolve the packed node references the timeline custom segments
/// parsed during load: each packed id is mapped through the load-time
/// id table to the live graph id (Rust files write packed identities;
/// C++ files use arbitrary pointer values). Unresolvable references are
/// dropped. Track kinds missing from the custom segment (C++ files)
/// are derived from the sequence `track_in_%1` connection.
fn resolve_timeline_refs(graph: &mut Graph, id_map: &std::collections::HashMap<u64, NodeId>) {
let resolve = |id: &NodeId| id_map.get(&id.identity()).copied();
for id in graph.node_ids() {
let entry = match graph.get_mut(id) {
Some(e) => e,
None => continue,
};
let behavior = &mut *entry.behavior;
if let Some(s) = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<crate::sequence::SequenceBehavior>())
{
s.track_lists = s.track_lists.iter().filter_map(resolve).collect();
} else if let Some(tl) = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<crate::track::TrackListBehavior>())
{
tl.tracks = tl.tracks.iter().filter_map(resolve).collect();
tl.sequence = tl.sequence.and_then(|s| resolve(&s));
} else if let Some(t) = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<crate::track::TrackBehavior>())
{
t.blocks = t.blocks.iter().filter_map(resolve).collect();
t.track_list = t.track_list.and_then(|l| resolve(&l));
} else if let Some(c) = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<crate::block::ClipBlockBehavior>())
{
c.core.track = c.core.track.and_then(|t| resolve(&t));
c.footage = c.footage.and_then(|f| resolve(&f));
// Block links mirror the node links (C++ LinkChangeEvent).
c.core.links = entry.core.links.clone();
} else if let Some(g) = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<crate::block::GapBlockBehavior>())
{
g.core.track = g.core.track.and_then(|t| resolve(&t));
g.core.links = entry.core.links.clone();
} else if let Some(t) = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<crate::block::TransitionBlockBehavior>())
{
t.core.track = t.core.track.and_then(|r| resolve(&r));
t.core.links = entry.core.links.clone();
} else if let Some(f) = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<crate::footage::FootageBehavior>())
{
// C++ files carry the file name in the `file_in` input.
if f.filename.is_empty() {
if let crate::value::NodeValue::Text(s) =
&entry.core.standard_value("file_in", -1)
{
f.filename = s.clone();
}
}
}
}
// Track kinds absent from the custom segment (C++ files) are
// derived from the sequence `track_in_%1` input the track feeds
// (`track_in_0` = video, `track_in_1` = audio, `track_in_2` =
// subtitle — `// CPP-PARITY: sequence.h`).
let mut kind_fixes: Vec<(NodeId, crate::track::TrackType)> = Vec::new();
for id in graph.node_ids() {
let is_track = graph
.get(id)
.map(|e| {
e.behavior
.as_any()
.and_then(|a| a.downcast_ref::<crate::track::TrackBehavior>())
.is_some()
})
.unwrap_or(false);
if !is_track {
continue;
}
for (_target, input_id, _element) in graph.output_connections(id) {
if let Some(base) = input_id.strip_prefix("track_in_") {
if let Ok(n) = base.parse::<i32>() {
if let Some(kind) = crate::track::TrackType::from_c(n) {
kind_fixes.push((id, kind));
break;
}
}
}
}
}
for (id, kind) in kind_fixes {
if let Some(entry) = graph.get_mut(id) {
if let Some(t) = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<crate::track::TrackBehavior>())
{
t.kind = kind;
}
}
}
}
/// Fold C++ `child_in` connections into the folder children (the Rust
/// writer persists them in the folder custom segment) and reattach each
/// child to its bin folder (`// CPP-PARITY: folder.cpp:43`
/// `InputConnectedEvent` sets the child's folder).
fn resolve_folder_children(
graph: &mut Graph,
id_map: &std::collections::HashMap<u64, NodeId>,
) {
for id in graph.node_ids() {
let is_folder = graph
.get(id)
.map(|e| e.behavior.type_id() == "org.olivevideoeditor.Olive.folder")
.unwrap_or(false);
if !is_folder {
continue;
}
// Children attached through the input (C++ files).
let size = graph
.get(id)
.map(|e| e.core.input_array_size("child_in"))
.unwrap_or(0);
let mut connected = Vec::new();
for element in 0..size {
if let Some(child) = graph.connected_output(id, "child_in", element as i32) {
connected.push(child);
}
}
// Resolve the custom-parsed children, then merge the
// input-derived ones.
let children = {
let entry = graph.get_mut(id).unwrap();
let folder = match entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<crate::folder::FolderBehavior>())
{
Some(f) => f,
None => continue,
};
// The persisted display name lives in the node label
// (C++ `Folder::Name()` returns the label).
if !entry.core.label.is_empty() {
folder.name = entry.core.label.clone();
}
folder.children = folder
.children
.iter()
.filter_map(|c| id_map.get(&c.identity()).copied())
.collect();
for c in connected {
folder.add_child(c);
}
folder.children.clone()
};
// Reattach each child to its bin folder.
for c in children {
if let Some(e) = graph.get_mut(c) {
e.core.bin_folder = Some(id);
}
}
}
}
/// Lock a project mutex (poison-tolerant).
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
+159
View File
@@ -119,6 +119,14 @@ impl TrackBehavior {
}
}
/// 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()
@@ -274,6 +282,13 @@ impl TrackListBehavior {
}
}
/// 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()
@@ -327,6 +342,85 @@ impl NodeBehavior for TrackBehavior {
}))
}
/// 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)
}
@@ -362,6 +456,71 @@ impl NodeBehavior for TrackListBehavior {
}))
}
/// 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)
}