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