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
Generated
+1232 -7
View File
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -17,13 +17,7 @@
# Root manifest: the `oakapp` package (the gpui-based application) plus the
# Cargo workspace over every crate under crates/.
#
# oakstorage (crates/oakstorage) is deliberately excluded: it is a work in
# progress whose contract tests are still `todo!()` stubs (they cannot pass
# yet), and it pulls heavy database backends (sea-orm). It stays a
# standalone crate with its own Cargo.lock; build it with
# `cd crates/oakstorage && cargo build`.
#
# gpui (the oak-gpui fork at gpui/) is excluded too: it is a separate git
# gpui (the oak-gpui fork at gpui/) is excluded: it is a separate git
# repository with its own workspace (resolver 3, edition 2024,
# workspace.package/workspace.dependencies). Without the exclusion its
# crates would be auto-included here via oakapp's path dependencies and
@@ -32,7 +26,11 @@
# workspace root, exactly as before the monorepo workspace existed.
[workspace]
members = ["crates/*"]
exclude = ["crates/oakstorage", "gpui"]
exclude = ["gpui"]
# NOTE: oakstorage (crates/oakstorage) is a workspace member but NOT a
# default member (it stays out of the default `cargo build`/`cargo test` at
# the root, which would also drag in its heavy database backends — sea-orm).
# Build/test it explicitly with `cargo test -p oakstorage`.
# NOTE: `crates/oakengine` is deliberately NOT a default member (it stays a
# workspace member, so `cargo test -p oakengine` works): its in-flight
# integration tests (`tests/it_*族.rs`, an ongoing rewrite) share temp files
+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)
}
+728
View File
@@ -141,3 +141,731 @@ fn corrupt_and_future_files_rejected() {
// Garbage text.
assert!(oaknode::serializer::load("not xml at all").is_err());
}
/// The C++-era fixture project (`tests/project_with_footage.ove`, a
/// 230220 full save with the `<olive>`/`<project>` container) loads with
/// its bin and timeline structure intact: the root folder holds the
/// footage + sequence children, the footage file name and timestamp
/// survive, and the sequence's viewer connections resolve.
#[test]
fn golden_project_with_footage_loads() {
use oaknode::folder::FolderBehavior;
use oaknode::footage::FootageBehavior;
use oaknode::sequence::SequenceBehavior;
let xml = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../tests/project_with_footage.ove"
))
.unwrap();
let project = oaknode::serializer::load(&xml).unwrap();
let p = project.lock().unwrap();
// The root folder (from the settings "root" key) holds the bin.
let root = p.graph.get(p.root).expect("root folder resolves");
assert_eq!(
root.behavior.type_id(),
"org.olivevideoeditor.Olive.folder"
);
let folder = root
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FolderBehavior>())
.expect("root is a folder");
assert_eq!(folder.name, "Root");
assert_eq!(folder.children.len(), 2, "folder holds footage + sequence");
// Child 0: footage with the demo.mp4 file name (the C++ fixture
// stores it in the `file_in` input) and its media timestamp.
let footage_id = folder.children[0];
let footage_entry = p.graph.get(footage_id).unwrap();
assert_eq!(
footage_entry.behavior.type_id(),
"org.olivevideoeditor.Olive.footage"
);
let footage = footage_entry
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FootageBehavior>())
.unwrap();
assert_eq!(footage.filename, "demo.mp4");
assert_eq!(footage.timestamp, 1780763070093);
// Child 1: the sequence, connected to the footage (tex_in/samples_in).
let seq_id = folder.children[1];
let seq_entry = p.graph.get(seq_id).unwrap();
assert_eq!(
seq_entry.behavior.type_id(),
"org.olivevideoeditor.Olive.sequence"
);
assert_eq!(seq_entry.core.label, "Fixture Sequence");
let seq = seq_entry
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
.unwrap();
// The fixture carries no tracks: the three `track_in_%1` inputs
// exist but no track-list nodes are present.
assert!(seq.track_lists.is_empty());
assert_eq!(
p.graph.connected_output(seq_id, "tex_in", -1),
Some(footage_id)
);
assert_eq!(
p.graph.connected_output(seq_id, "samples_in", -1),
Some(footage_id)
);
// Settings survived (incl. the root key).
assert_eq!(
p.settings.get("root").map(String::as_str),
Some("94432914284304")
);
}
/// Build a full-featured project for the timeline round-trip: a root
/// folder holding a footage (streams/proxy/timestamp) and a sequence
/// with video/audio track lists, tracks with clips and a gap, an effect
/// chain with keyframes, plus settings and a link.
fn build_full_project() -> std::sync::Arc<std::sync::Mutex<oaknode::project::Project>> {
use oakcore_rs::{Rational, TimeRange};
use oaknode::block::{clip_create, gap_create, ClipBlockBehavior, GapBlockBehavior};
use oaknode::folder::FolderBehavior;
use oaknode::footage::{FootageBehavior, StreamInfo};
use oaknode::keyframe::{Interpolation, Keyframe};
use oaknode::node::NodeCore;
use oaknode::project::Project;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
use oaknode::value::{AudioParams, NodeValue, VideoParams};
let project = Project::new();
let mut p = project.lock().unwrap();
p.initialize().unwrap();
p.settings
.insert("projectname".to_string(), "full-featured".to_string());
let folder_id = p.root;
// Footage with streams, proxy state and a media timestamp.
let footage_id = {
let (core, mut behavior) = FootageBehavior::create();
let f = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<FootageBehavior>())
.unwrap();
f.filename = "/media/demo.mp4".to_string();
f.timestamp = 1780763070093;
f.proxy = "/media/demo_proxy.mp4".to_string();
f.proxy_enabled = true;
f.proxy_state = 2;
f.proxy_video_stream_index = 0;
f.proxy_preset_version = 1;
f.streams = vec![
StreamInfo {
index: 0,
is_video: true,
video: Some(VideoParams {
width: 1920,
height: 1080,
frame_rate: Rational::new(25, 1),
pixel_format: 4,
channels: 4,
}),
audio: None,
duration: Rational::new(217600, 12800),
},
StreamInfo {
index: 1,
is_video: false,
video: None,
audio: Some(AudioParams {
sample_rate: 48000,
channel_layout: 3,
format: 4,
}),
duration: Rational::new(816000, 48000),
},
];
p.graph.add_node(core, behavior)
};
{
let entry = p.graph.get_mut(folder_id).unwrap();
let folder = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<FolderBehavior>())
.unwrap();
folder.add_child(footage_id);
}
// Sequence + its video/audio track lists.
let (score, sbehavior) = SequenceBehavior::create();
let seq_id = p.graph.add_node(score, sbehavior);
let vlist_id = {
let mut behavior = TrackListBehavior::new(TrackType::Video);
behavior.sequence = Some(seq_id);
behavior.array_base = 0;
p.graph.add_node(NodeCore::new(), Box::new(behavior))
};
let alist_id = {
let mut behavior = TrackListBehavior::new(TrackType::Audio);
behavior.sequence = Some(seq_id);
behavior.array_base = 1;
p.graph.add_node(NodeCore::new(), Box::new(behavior))
};
// Video track: clips + gap.
let vtrack_id = {
let mut behavior = TrackBehavior::new(TrackType::Video);
behavior.track_list = Some(vlist_id);
behavior.index = 0;
behavior.height = 4.0;
behavior.muted = true;
p.graph.add_node(NodeCore::new(), Box::new(behavior))
};
let clip1_id = {
let (core, mut behavior) = clip_create();
let c = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
.unwrap();
c.core.range = TimeRange::new(Rational::new(0, 1), Rational::new(100, 25));
c.core.media_in = Rational::new(0, 1);
c.core.speed = 1.0;
c.core.enabled = true;
c.core.track = Some(vtrack_id);
c.footage = Some(footage_id);
p.graph.add_node(core, behavior)
};
let gap1_id = {
let (core, mut behavior) = gap_create();
let g = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<GapBlockBehavior>())
.unwrap();
g.core.range = TimeRange::new(Rational::new(100, 25), Rational::new(120, 25));
g.core.track = Some(vtrack_id);
p.graph.add_node(core, behavior)
};
let clip2_id = {
let (core, mut behavior) = clip_create();
let c = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
.unwrap();
c.core.range = TimeRange::new(Rational::new(120, 25), Rational::new(220, 25));
c.core.media_in = Rational::new(10, 25);
c.core.speed = 1.5;
c.core.reversed = true;
c.core.loop_mode = 2;
c.core.track = Some(vtrack_id);
c.footage = Some(footage_id);
p.graph.add_node(core, behavior)
};
// An opacity effect on clip1 with a keyframed value.
let _effect_id = {
let (core, behavior) = (oaknode::factory::Factory::global()
.find("org.olivevideoeditor.Olive.opacity")
.unwrap()
.create)();
let id = p.graph.add_node(core, behavior);
p.graph.get_mut(id).unwrap().core.set_standard_value(
"opacity_in",
-1,
NodeValue::Float(0.5),
);
p.graph
.get_mut(id)
.unwrap()
.core
.keyframe_track_mut("opacity_in", -1)
.set_key(Keyframe {
time: Rational::new(0, 1),
value: NodeValue::Float(1.0),
interpolation: Interpolation::Linear,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
p.graph
.get_mut(id)
.unwrap()
.core
.keyframe_track_mut("opacity_in", -1)
.set_key(Keyframe {
time: Rational::new(1, 1),
value: NodeValue::Float(0.0),
interpolation: Interpolation::Bezier,
bezier_in: (0.1, 0.2),
bezier_out: (0.3, 0.4),
});
p.graph.connect(id, clip1_id, "tex_in", -1).unwrap();
id
};
// Audio track + its clip.
let atrack_id = {
let mut behavior = TrackBehavior::new(TrackType::Audio);
behavior.track_list = Some(alist_id);
behavior.index = 0;
behavior.locked = true;
p.graph.add_node(NodeCore::new(), Box::new(behavior))
};
let clip3_id = {
let (core, mut behavior) = clip_create();
let c = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
.unwrap();
c.core.range = TimeRange::new(Rational::new(0, 1), Rational::new(50, 25));
c.core.media_in = Rational::new(5, 25);
c.core.track = Some(atrack_id);
c.footage = Some(footage_id);
p.graph.add_node(core, behavior)
};
// Wire the hierarchy (behavior fields, the Rust model).
{
let entry = p.graph.get_mut(seq_id).unwrap();
entry.core.label = "Full Sequence".to_string();
entry.core.override_color = 3;
let s = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
.unwrap();
s.track_lists = vec![vlist_id, alist_id];
}
// The bin: the sequence joins the folder after the footage.
{
let entry = p.graph.get_mut(folder_id).unwrap();
let folder = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<FolderBehavior>())
.unwrap();
folder.add_child(seq_id);
}
{
let entry = p.graph.get_mut(vlist_id).unwrap();
let tl = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
.unwrap();
tl.tracks = vec![vtrack_id];
}
{
let entry = p.graph.get_mut(vtrack_id).unwrap();
let t = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackBehavior>())
.unwrap();
t.blocks = vec![clip1_id, gap1_id, clip2_id];
}
{
let entry = p.graph.get_mut(alist_id).unwrap();
let tl = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
.unwrap();
tl.tracks = vec![atrack_id];
}
{
let entry = p.graph.get_mut(atrack_id).unwrap();
let t = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackBehavior>())
.unwrap();
t.blocks = vec![clip3_id];
}
// Link the two video clips.
p.graph.link(clip1_id, clip2_id);
drop(p);
project
}
/// Borrowed track list of a node.
fn list_of<'a>(
p: &'a oaknode::project::Project,
id: oaknode::id::NodeId,
) -> &'a oaknode::track::TrackListBehavior {
p.graph
.get(id)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<oaknode::track::TrackListBehavior>())
.unwrap()
}
/// Field-by-field comparison of the round-tripped full project.
fn assert_full_roundtrip_fields(orig: &oaknode::project::Project, loaded: &oaknode::project::Project) {
use oaknode::block::ClipBlockBehavior;
use oaknode::folder::FolderBehavior;
use oaknode::footage::FootageBehavior;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
// Project shell: uuid + settings.
assert_eq!(loaded.uuid, orig.uuid, "uuid");
assert_eq!(loaded.settings, orig.settings, "settings");
// The graph keeps its node count, types and edge count.
assert_eq!(loaded.graph.node_count(), orig.graph.node_count(), "node count");
let o_types: Vec<&str> = orig
.graph
.node_ids()
.iter()
.map(|id| orig.graph.get(*id).unwrap().behavior.type_id())
.collect();
let l_types: Vec<&str> = loaded
.graph
.node_ids()
.iter()
.map(|id| loaded.graph.get(*id).unwrap().behavior.type_id())
.collect();
assert_eq!(l_types, o_types, "node types");
assert_eq!(
loaded.graph.output_connections_all().len(),
orig.graph.output_connections_all().len(),
"edge count"
);
// Map original id -> loaded id (slot order is preserved).
let o_ids = orig.graph.node_ids();
let l_ids = loaded.graph.node_ids();
// Root folder: children + bin membership.
let of = orig
.graph
.get(orig.root)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FolderBehavior>())
.unwrap();
let lf = loaded
.graph
.get(loaded.root)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FolderBehavior>())
.unwrap();
assert_eq!(lf.name, of.name, "folder name");
assert_eq!(lf.children.len(), of.children.len(), "folder children");
let o_footage = of.children[0];
let o_seq = of.children[1];
let l_footage = lf.children[0];
let l_seq = lf.children[1];
// Footage: filename, timestamp, proxy and streams.
let o_f = orig
.graph
.get(o_footage)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FootageBehavior>())
.unwrap();
let l_f = loaded
.graph
.get(l_footage)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FootageBehavior>())
.unwrap();
assert_eq!(l_f.filename, o_f.filename, "footage filename");
assert_eq!(l_f.timestamp, o_f.timestamp, "footage timestamp");
assert_eq!(l_f.proxy, o_f.proxy, "footage proxy path");
assert_eq!(l_f.proxy_enabled, o_f.proxy_enabled, "proxy enabled");
assert_eq!(l_f.proxy_state, o_f.proxy_state, "proxy state");
assert_eq!(
l_f.proxy_video_stream_index, o_f.proxy_video_stream_index,
"proxy stream"
);
assert_eq!(l_f.proxy_preset_version, o_f.proxy_preset_version, "proxy preset");
assert_eq!(l_f.streams.len(), o_f.streams.len(), "stream count");
for (ls, os) in l_f.streams.iter().zip(&o_f.streams) {
assert_eq!(ls.index, os.index, "stream index");
assert_eq!(ls.is_video, os.is_video, "stream video flag");
assert_eq!(ls.video, os.video, "stream video params");
assert_eq!(ls.audio, os.audio, "stream audio params");
assert_eq!(ls.duration, os.duration, "stream duration");
}
// Sequence: label/color, track lists (kind, base, backrefs).
let o_s = orig
.graph
.get(o_seq)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
.unwrap();
let l_s = loaded
.graph
.get(l_seq)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
.unwrap();
assert_eq!(
loaded.graph.get(l_seq).unwrap().core.label,
orig.graph.get(o_seq).unwrap().core.label,
"sequence label"
);
assert_eq!(
loaded.graph.get(l_seq).unwrap().core.override_color,
orig.graph.get(o_seq).unwrap().core.override_color,
"sequence color"
);
assert_eq!(l_s.track_lists.len(), o_s.track_lists.len(), "track list count");
let (o_vlist, l_vlist) = (o_s.track_lists[0], l_s.track_lists[0]);
let (o_alist, l_alist) = (o_s.track_lists[1], l_s.track_lists[1]);
let o_vl = list_of(orig, o_vlist);
let l_vl = list_of(loaded, l_vlist);
assert_eq!(l_vl.kind, o_vl.kind, "video list kind");
assert_eq!(l_vl.array_base, o_vl.array_base, "video list base");
assert_eq!(l_vl.sequence, Some(l_seq), "video list sequence backref");
assert_eq!(l_vl.tracks.len(), o_vl.tracks.len(), "video list track count");
let (o_vtrack, l_vtrack) = (o_vl.tracks[0], l_vl.tracks[0]);
let o_al = list_of(orig, o_alist);
let l_al = list_of(loaded, l_alist);
assert_eq!(l_al.kind, o_al.kind, "audio list kind");
assert_eq!(l_al.array_base, o_al.array_base, "audio list base");
assert_eq!(l_al.sequence, Some(l_seq), "audio list sequence backref");
assert_eq!(l_al.tracks.len(), o_al.tracks.len(), "audio list track count");
let (o_atrack, l_atrack) = (o_al.tracks[0], l_al.tracks[0]);
// Video track: kind/blocks/muted/locked/height/index/backref.
let o_t = orig
.graph
.get(o_vtrack)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<TrackBehavior>())
.unwrap();
let l_t = loaded
.graph
.get(l_vtrack)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<TrackBehavior>())
.unwrap();
assert_eq!(l_t.kind, TrackType::Video, "video track kind");
assert_eq!(l_t.muted, o_t.muted, "video track muted");
assert_eq!(l_t.locked, o_t.locked, "video track locked");
assert_eq!(l_t.height, o_t.height, "video track height");
assert_eq!(l_t.index, o_t.index, "video track index");
assert_eq!(l_t.track_list, Some(l_vlist), "video track list backref");
assert_eq!(l_t.blocks.len(), o_t.blocks.len(), "video track block count");
let o_clip1 = o_t.blocks[0];
let o_gap1 = o_t.blocks[1];
let o_clip2 = o_t.blocks[2];
let l_clip1 = l_t.blocks[0];
let l_gap1 = l_t.blocks[1];
let l_clip2 = l_t.blocks[2];
// Clip 1: range/media_in/speed/reversed/enabled/pitch/loop, track
// and footage backrefs, and the effect connection.
let o_c1 = orig
.graph
.get(o_clip1)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
.unwrap();
let l_c1 = loaded
.graph
.get(l_clip1)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
.unwrap();
assert_eq!(l_c1.core.range, o_c1.core.range, "clip1 range");
assert_eq!(l_c1.core.media_in, o_c1.core.media_in, "clip1 media in");
assert_eq!(l_c1.core.speed, o_c1.core.speed, "clip1 speed");
assert_eq!(l_c1.core.reversed, o_c1.core.reversed, "clip1 reversed");
assert_eq!(l_c1.core.enabled, o_c1.core.enabled, "clip1 enabled");
assert_eq!(
l_c1.core.maintain_audio_pitch, o_c1.core.maintain_audio_pitch,
"clip1 pitch"
);
assert_eq!(l_c1.core.loop_mode, o_c1.core.loop_mode, "clip1 loop");
assert_eq!(l_c1.core.track, Some(l_vtrack), "clip1 track backref");
assert_eq!(l_c1.footage, Some(l_footage), "clip1 footage backref");
// Gap 1: range + track backref.
let o_g1 = orig
.graph
.get(o_gap1)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<oaknode::block::GapBlockBehavior>())
.unwrap();
let l_g1 = loaded
.graph
.get(l_gap1)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<oaknode::block::GapBlockBehavior>())
.unwrap();
assert_eq!(l_g1.core.range, o_g1.core.range, "gap range");
assert_eq!(l_g1.core.track, Some(l_vtrack), "gap track backref");
// Clip 2 (speed/reversed set).
let o_c2 = orig
.graph
.get(o_clip2)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
.unwrap();
let l_c2 = loaded
.graph
.get(l_clip2)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
.unwrap();
assert_eq!(l_c2.core.range, o_c2.core.range, "clip2 range");
assert_eq!(l_c2.core.media_in, o_c2.core.media_in, "clip2 media in");
assert_eq!(l_c2.core.speed, o_c2.core.speed, "clip2 speed");
assert_eq!(l_c2.core.reversed, o_c2.core.reversed, "clip2 reversed");
assert_eq!(l_c2.core.loop_mode, o_c2.core.loop_mode, "clip2 loop");
assert_eq!(l_c2.footage, Some(l_footage), "clip2 footage backref");
// Audio track + clip 3.
let o_at = orig
.graph
.get(o_atrack)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<TrackBehavior>())
.unwrap();
let l_at = loaded
.graph
.get(l_atrack)
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<TrackBehavior>())
.unwrap();
assert_eq!(l_at.kind, TrackType::Audio, "audio track kind");
assert_eq!(l_at.locked, o_at.locked, "audio track locked");
assert_eq!(l_at.track_list, Some(l_alist), "audio track list backref");
assert_eq!(l_at.blocks.len(), o_at.blocks.len(), "audio track block count");
let o_c3 = orig
.graph
.get(o_at.blocks[0])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
.unwrap();
let l_c3 = loaded
.graph
.get(l_at.blocks[0])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
.unwrap();
assert_eq!(l_c3.core.range, o_c3.core.range, "clip3 range");
assert_eq!(l_c3.core.media_in, o_c3.core.media_in, "clip3 media in");
assert_eq!(l_c3.core.track, Some(l_atrack), "clip3 track backref");
assert_eq!(l_c3.footage, Some(l_footage), "clip3 footage backref");
// Effect chain: the effect feeds clip1's tex_in.
let o_effect = orig
.graph
.connected_output(o_clip1, "tex_in", -1)
.expect("clip1 has an effect");
let l_effect = loaded
.graph
.connected_output(l_clip1, "tex_in", -1)
.expect("clip1 has an effect after load");
let o_effect_i = o_ids.iter().position(|id| *id == o_effect).unwrap();
let l_effect_i = l_ids.iter().position(|id| *id == l_effect).unwrap();
assert_eq!(l_effect_i, o_effect_i, "effect slot");
// The effect's keyframes survive.
let ok = orig
.graph
.get(o_effect)
.unwrap()
.core
.keyframe_track("opacity_in", -1)
.unwrap()
.keys()
.to_vec();
let lk = loaded
.graph
.get(l_effect)
.unwrap()
.core
.keyframe_track("opacity_in", -1)
.unwrap()
.keys()
.to_vec();
assert_eq!(lk.len(), ok.len(), "keyframe count");
for (ko, kl) in ok.iter().zip(&lk) {
assert_eq!(kl.time, ko.time, "key time");
assert_eq!(kl.value.to_double(), ko.value.to_double(), "key value");
assert_eq!(kl.interpolation, ko.interpolation, "key interpolation");
assert_eq!(kl.bezier_in, ko.bezier_in, "key bezier in");
assert_eq!(kl.bezier_out, ko.bezier_out, "key bezier out");
}
// The clip link survives.
assert!(
loaded.graph.are_linked(l_clip1, l_clip2),
"clip link survives"
);
assert!(
loaded.graph.are_linked(l_clip2, l_clip1),
"clip link symmetric"
);
}
/// Round-trip the full-featured project: save, load, compare field by
/// field, and re-save idempotently.
#[test]
fn roundtrip_full_timeline() {
let project = build_full_project();
let xml = {
let p = project.lock().unwrap();
oaknode::serializer::save(&p).unwrap()
};
let loaded = oaknode::serializer::load(&xml).unwrap();
{
let o = project.lock().unwrap();
let l = loaded.lock().unwrap();
assert_full_roundtrip_fields(&o, &l);
}
// Re-save is idempotent (byte-identical).
let xml2 = {
let l = loaded.lock().unwrap();
oaknode::serializer::save(&l).unwrap()
};
assert_eq!(xml, xml2, "re-save is idempotent");
}
+11 -3
View File
@@ -8,9 +8,6 @@ license = "GPL-3.0-or-later"
[lib]
crate-type = ["staticlib", "rlib"]
[profile.release]
panic = "unwind"
[dependencies]
oakcore-rs = { path = "../oakcore" }
serde = { version = "1", features = ["derive"] }
@@ -18,6 +15,10 @@ serde_json = { version = "1", features = ["preserve_order"] }
# Error derive (Display + std::error::Error) for the crate error enum
# (src/error.rs). Same major version the other modules use (oakotio, ...).
thiserror = "2"
# The node graph engine: project + serializer families power the ove-xml
# backend; the sequence/track/block model powers the otio interchange
# (see src/bridge/node.rs — direct Rust calls, single-lib unification).
oaknode = { path = "../oaknode" }
# Native OTIO JSON model (project-local; see src/bindings/oakotio).
oakotio = { path = "../oakotio" }
# Database backends via SeaORM (sync facade: a private current-thread
@@ -25,3 +26,10 @@ oakotio = { path = "../oakotio" }
# backends/database.rs). sqlx-postgres + sqlx-sqlite cover PG/SQLite.
sea-orm = { version = "2", features = ["sqlx-postgres", "sqlx-sqlite", "runtime-tokio"] }
tokio = { version = "1", features = ["rt", "macros"] }
[dev-dependencies]
# oakcodec's oakcore_*/oakrender_* host-mocks (test-stubs feature): the
# oakstorage test binary links the real oakcodec (via oaknode), whose
# ffmpeg unit references those cross-crate symbols that no Rust crate
# provides (same setup as oaknode's own dev-dependencies).
oakcodec = { path = "../oakcodec", features = ["test-stubs"] }
+43 -28
View File
@@ -1,45 +1,51 @@
# oakstorage Rust crate (declaration draft, for review)
# oakstorage Rust crate — project persistence
> Status: **declaration draft**. Signatures + doc comments are the
> spec; every body is `todo!()`. Manual: docs/zh/plans/riir/M10-oakstorage.md.
> Status: **implemented** (file backends). Manual:
> docs/zh/plans/riir/M10-oakstorage.md.
## Scope
Project persistence — the single module that knows where projects come
from and where they are saved to. Backends are pluggable via a manual
vtable; shipping in this pass: `ove-xml` (the existing XML project
format), `otio` (via the native oakotio crate), and **database**
(PostgreSQL + SQLite). Consumers never branch on backend.
vtable; shipping in this pass: `ove-xml` (the XML project format) and
`otio` (the `.otio` / `.fcpxml` interchange, via the native oakotio
crate). The **database** backend (PostgreSQL + SQLite, SeaORM) is a
declared stub for a later proxy — not registered, `todo!()` bodies.
Consumers never branch on backend.
## Architectural decisions
1. **URI dispatch, not file paths.** Every entry point takes a URI:
`file:///…proj.ove` / `file:///…proj.otio` /
`oakdb+sqlite:///path/to.db` / `oakdb+pg://host:port/dbname?user=…`.
The core resolves scheme + backend `can_handle` arbitration
(M10 §2.3).
`file:///…proj.ove` / `file:///…proj.otio` / `oakdb://…`. Bare
paths are normalized to `file://`. The core resolves scheme +
backend `can_handle` arbitration (M10 §2.3).
2. **Manual vtable backends** (`backend.rs` `StorageBackend` trait =
the M10 C vtable's Rust shape). The public C ABI vtable
(`oakstorage_backend_register`) accepts foreign (C-side) backends;
in-crate backends implement the Rust trait directly.
(`oakstorage_backend_register`) accepts foreign (C-side) backends
the database-swap interface proof — and in-crate backends implement
the Rust trait directly.
3. **The graph (de)serialization itself stays in oaknode** — every
backend calls the oaknode C ABI (`oaknode_serializer_*` family)
through `bridge::node` to fetch/rebuild the in-memory graph;
backends own framing: container bytes, schema, versioning,
compression, sessions.
backend calls the oaknode serializer (`oaknode::serializer::load` /
`save`) through `bridge::node` (direct Rust calls, single-lib
unification) to fetch/rebuild the in-memory graph; backends own
framing: container bytes, schema, versioning (TOO_OLD/TOO_NEW/
UNKNOWN_VERSION), sessions. `OAKSTORAGE_SAVE_COMPRESS` is accepted
but not implemented (the oaknode serializer emits plain XML only).
4. **Database backend shares one logical schema** across PostgreSQL
and SQLite via **SeaORM** (`sea-orm` crate, sqlx-postgres +
sqlx-sqlite features): a private current-thread tokio runtime drives
the async SeaORM API behind the synchronous C ABI; the entity set is
and SQLite via **SeaORM**: a private current-thread tokio runtime
drives the async API behind the synchronous C ABI; the entity set is
minimal (projects table: id, name, payload blob, version,
timestamps). Schema management (create-if-missing, migrate) is
backend-internal. The graph payload is the same serialized form the
timestamps). The graph payload is the same serialized form the
ove-xml backend uses — one serialization truth, two containers.
5. **No callbacks/events** (M10: synchronous commands only; the caller
— oaktask/facade — owns progress reporting).
6. **Errors** follow the project -MMCCCC scheme, module 10
(`-100001` …); the M10 positive info codes (TOO_OLD/TOO_NEW/…)
are kept verbatim.
(`-100001` …); the M10 positive info codes (TOO_OLD/TOO_NEW/…) are
kept verbatim.
7. **Interchange is lossy.** The otio backend's export/import mapping
preserves sequences/tracks/clips/gaps/transitions; effect chains,
keyframes, project bins/settings and exact rational timebases are
not carried (see the module docs in `backends/otio.rs`).
## Layout
@@ -47,16 +53,25 @@ format), `otio` (via the native oakotio crate), and **database**
src/
lib.rs crate doc + module map
error.rs error/info codes (M10 §2.1, -MMCCCC module 10)
handle.rs refcounted-handle scaffolding
handle.rs refcounted-handle scaffolding (shared oakcore CHandle)
uri.rs URI parsing/classification
session.rs StorageProject session (open/take/uri)
registry.rs backend registry (register/unregister/arbitrate)
backend.rs StorageBackend trait + C vtable marshalling
backend.rs StorageBackend trait + LoadResult
backends/
ove_xml.rs built-in .ove XML backend (via bridge::node)
otio.rs built-in .otio backend (via oakotio)
otio.rs built-in .otio/.fcpxml backend (via oakotio)
database.rs declared stub (later proxy)
bridge/
node.rs oaknode C ABI imports (serializer family)
ffi.rs export layer (M10 §2.2 verbatim)
node.rs oaknode calls (project + serializer + sequence builder)
ffi.rs export layer (M10 §2.2/§2.3 verbatim)
tests/ contract tests incl. the pluggability proof
```
## Build / test
A workspace member (not a default member); build and test explicitly:
```
cargo test -p oakstorage
```
+48 -4
View File
@@ -20,21 +20,65 @@
use crate::uri::StorageUri;
/// Outcome of a backend load: the loaded project plus the version
/// information code the caller surfaces through `oakstorage_open`'s
/// `result_code` (M10 §2.2). Version probing (TOO_OLD / TOO_NEW /
/// UNKNOWN_VERSION) is the backend's own judgement; a "not loadable"
/// outcome carries an empty `project` handle and a positive info code,
/// so the caller can report it instead of a hard error.
#[derive(Clone, Debug)]
pub struct LoadResult {
/// The loaded project handle (owned, refcount 1; empty when version
/// probing declined to load).
pub project: crate::handle::CHandle,
/// Version info code: `OAKSTORAGE_OK` or a positive info code
/// (TOO_OLD / TOO_NEW / UNKNOWN_VERSION).
pub version_info: i32,
}
impl LoadResult {
/// A normal successful load.
pub fn success(project: crate::handle::CHandle) -> Self {
LoadResult {
project,
version_info: crate::error::OAKSTORAGE_OK,
}
}
/// A loaded project plus an info code (e.g. TOO_OLD: the project was
/// written by an older build and has been upgraded on the fly).
pub fn with_info(project: crate::handle::CHandle, version_info: i32) -> Self {
LoadResult {
project,
version_info,
}
}
/// No project — only an info code (TOO_NEW / UNKNOWN_VERSION).
pub fn info_only(version_info: i32) -> Self {
LoadResult {
project: crate::handle::CHandle::null(),
version_info,
}
}
}
/// A storage backend (M10 §2.3 vtable semantics).
pub trait StorageBackend: Send + Sync {
/// Backend name ("ove-xml" / "otio" / "oakdb").
fn name(&self) -> &'static str;
fn name(&self) -> &str;
/// URI scheme this backend serves ("file" / "oakdb").
fn uri_scheme(&self) -> &'static str;
fn uri_scheme(&self) -> &str;
/// Whether this backend claims the URI (suffix, magic bytes,
/// reachability — backend's own judgement).
fn can_handle(&self, uri: &StorageUri) -> bool;
/// Load a project; returns an owned oaknode project handle
/// (CHandle with refcount 1) or an error code/context.
fn load(&self, uri: &StorageUri) -> crate::error::Result<crate::handle::CHandle>;
/// (CHandle with refcount 1) wrapped in a [`LoadResult`], or an
/// error code/context.
fn load(&self, uri: &StorageUri) -> crate::error::Result<LoadResult>;
/// Save a project to the URI. `options` is the M10 bitmask
/// (OAKSTORAGE_SAVE_COMPRESS etc.); unknown bits are ignored.
+11 -7
View File
@@ -39,8 +39,12 @@ impl DatabaseBackend {
/// Parse the URI into a SeaORM connection URL + project key
/// (query param `?project=` or row id; default: singleton "default"
/// project row).
///
/// Dead until the database proxy lands (the whole backend is a
/// declared stub); kept for the schema contract.
#[allow(dead_code)]
pub(crate) fn parse_target(
uri: &crate::uri::StorageUri,
_uri: &crate::uri::StorageUri,
) -> crate::error::Result<(String, String)> {
todo!()
}
@@ -56,21 +60,21 @@ impl crate::backend::StorageBackend for DatabaseBackend {
}
fn can_handle(&self, uri: &crate::uri::StorageUri) -> bool {
todo!()
uri.scheme.starts_with("oakdb")
}
fn load(
&self,
uri: &crate::uri::StorageUri,
) -> crate::error::Result<crate::handle::CHandle> {
_uri: &crate::uri::StorageUri,
) -> crate::error::Result<crate::backend::LoadResult> {
todo!()
}
fn save(
&self,
project: crate::handle::CHandle,
uri: &crate::uri::StorageUri,
options: u32,
_project: crate::handle::CHandle,
_uri: &crate::uri::StorageUri,
_options: u32,
) -> crate::error::Result<()> {
todo!()
}
+8 -1
View File
@@ -15,6 +15,10 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Built-in backends.
//!
//! The database backend (`database`) is *not* registered: it is the
//! future replacement (M10 §3 — "数据库替换路径"), provided by a later
//! proxy. The file backends cover today's surface.
pub mod database;
pub mod otio;
@@ -25,5 +29,8 @@ use std::sync::Arc;
/// All built-in backends in arbitration order (registered into
/// [`crate::registry::Registry::global`] at crate init).
pub fn builtins() -> Vec<Arc<dyn crate::backend::StorageBackend>> {
todo!()
vec![
Arc::new(ove_xml::OveXmlBackend::new()),
Arc::new(otio::OtioBackend::new()),
]
}
+582 -18
View File
@@ -14,46 +14,610 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The `.otio` backend (import/export semantics via the native
//! `oakotio` crate). OTIO is a one-way interchange format: `load`
//! imports into a fresh project; `save` exports the current timeline.
//! The `.otio` / `.fcpxml` backend (import/export via the native
//! `oakotio` crate).
//!
//! OTIO and FCPXML are **interchange formats** — the mapping is lossy by
//! design (M10 §2.2 "otio 后端(import 语义)"). What round-trips:
//! sequences → timelines, track lists → tracks (kind "Video" /
//! "Audio" / "Subtitle"), clip blocks → clips (name, source range,
//! media reference), gap blocks → gaps, transition blocks →
//! transitions. What is **not** carried across (documented lossy
//! items, bounded by the oakotio model and the current oaknode model):
//!
//! - Effect chains and their parameters: a clip's `tex_in` effect row
//! (blur/opacity/... nodes) is not represented in OTIO's effect
//! model (oakotio stores effects as opaque `Value` blobs), so effects
//! are dropped on export.
//! - Keyframes and keyframe interpolation: clip parameters are not
//! exported; OTIO has no per-clip parameter animation in the
//! covered schema set.
//! - Exact rational timebases: times cross as seconds (oakotio
//! `RationalTime`), so numeric numerators/denominators change while
//! durations and offsets are preserved.
//! - Project bins (the folder tree), project settings, node labels and
//! colors, and non-timeline nodes (math, generators, ...) are not
//! part of the interchange.
//! - Clip ↔ footage linkage: exports the footage filename as an
//! `ExternalReference.target_url`; imports create a footage node per
//! clip. A clip without a resolvable footage reference exports a
//! `MissingReference`.
//! - Nested OTIO stacks, markers and transitions-in-depth are imported
//! approximately (a transition becomes a single transition block
//! with its offsets; nested stacks are skipped).
//!
//! Load always builds a fresh project (`initialize()` + one sequence
//! per timeline); save exports every sequence of the project.
/// The otio backend (`file://` + `.otio`).
use std::collections::HashSet;
use oakcore_rs::Rational;
use oakotio::model::{
Composable, ExternalReference, Gap, MediaReference, MissingReference, Serializable,
SerializableCollection, Timeline, TimeRange, Track, Transition,
};
use oaknode::block::{ClipBlockBehavior, GapBlockBehavior, TransitionBlockBehavior};
use oaknode::footage::FootageBehavior;
use oaknode::graph::NodeEntry;
use oaknode::id::NodeId;
use oaknode::node::NodeCore;
use oaknode::project::Project;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
use crate::backend::LoadResult;
use crate::bridge::node;
use crate::error::{Error, Result};
use crate::uri::StorageUri;
/// The otio backend (`file://` + `.otio` / `.fcpxml`).
pub struct OtioBackend;
/// Default frame rate used when a sequence has no video parameters.
const DEFAULT_FPS: f64 = 24.0;
impl OtioBackend {
/// Construct.
pub fn new() -> Self {
todo!()
OtioBackend
}
}
impl Default for OtioBackend {
fn default() -> Self {
Self::new()
}
}
impl crate::backend::StorageBackend for OtioBackend {
fn name(&self) -> &'static str {
todo!()
"otio"
}
fn uri_scheme(&self) -> &'static str {
todo!()
"file"
}
fn can_handle(&self, uri: &crate::uri::StorageUri) -> bool {
todo!()
fn can_handle(&self, uri: &StorageUri) -> bool {
matches!(uri.extension().as_deref(), Some("otio") | Some("fcpxml"))
}
fn load(
&self,
uri: &crate::uri::StorageUri,
) -> crate::error::Result<crate::handle::CHandle> {
todo!()
fn load(&self, uri: &StorageUri) -> Result<LoadResult> {
let path = uri.local_path().ok_or(Error::Invalid)?.to_string();
let ext = uri.extension().ok_or(Error::Invalid)?;
let timelines: Vec<Timeline> = match ext.as_str() {
"otio" => {
let text = std::fs::read_to_string(&path).map_err(|e| Error::Io(e.to_string()))?;
let root = oakotio::from_json_string(&text)
.map_err(|e| Error::Format(e.to_string()))?;
match root {
Serializable::Timeline(t) => vec![t],
Serializable::SerializableCollection(c) => c
.children()
.iter()
.filter_map(|s| s.as_timeline().cloned())
.collect(),
Serializable::Raw(_) => {
return Err(Error::Format(format!(
"'{}' has no timeline or collection root",
path
)));
}
}
}
"fcpxml" => {
oakotio::from_fcpxml_file(&path).map_err(|e| Error::Format(e.to_string()))?
}
_ => return Err(Error::Invalid),
};
let project = Project::new();
{
let mut guard = project.lock().map_err(|_| Error::State)?;
guard
.initialize()
.map_err(|e| Error::Failed(e.to_string()))?;
for timeline in &timelines {
import_timeline(&mut guard, timeline)?;
}
}
Ok(LoadResult::success(node::make_project_owned(project)))
}
fn save(
&self,
project: crate::handle::CHandle,
uri: &crate::uri::StorageUri,
options: u32,
) -> crate::error::Result<()> {
todo!()
uri: &StorageUri,
_options: u32,
) -> Result<()> {
let path = uri.local_path().ok_or(Error::Invalid)?.to_string();
let ext = uri.extension().ok_or(Error::Invalid)?;
let arc = unsafe { node::project_arc(&project)? };
let guard = arc.lock().map_err(|_| Error::State)?;
let timelines = project_to_timelines(&guard);
match ext.as_str() {
"otio" => {
let root = if timelines.len() == 1 {
Serializable::Timeline(timelines.into_iter().next().unwrap())
} else if timelines.is_empty() {
// Nothing to export; a single empty timeline is the
// friendliest shape for a fresh import.
Serializable::Timeline(Timeline::new("Timeline"))
} else {
let children: Vec<Serializable> = timelines
.into_iter()
.map(Serializable::Timeline)
.collect();
Serializable::SerializableCollection(SerializableCollection::new(
"oak",
children,
))
};
root.to_json_file(&path).map_err(|e| Error::Io(e.to_string()))
}
"fcpxml" => oakotio::to_fcpxml_file(&timelines, &path)
.map_err(|e| Error::Io(e.to_string())),
_ => Err(Error::Invalid),
}
}
}
// ---------------------------------------------------------------------------
// Project -> oakotio
// ---------------------------------------------------------------------------
/// Walk the project graph and build one [`Timeline`] per sequence node.
fn project_to_timelines(project: &Project) -> Vec<Timeline> {
let graph = &project.graph;
let mut timelines = Vec::new();
for id in graph.node_ids() {
let entry = match graph.get(id) {
Some(e) => e,
None => continue,
};
let seq = match entry
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
{
Some(s) => s,
None => continue,
};
let fps = sequence_fps(seq);
let name = if entry.core.label.is_empty() {
"Sequence".to_string()
} else {
entry.core.label.clone()
};
let mut timeline = Timeline::new(name);
for list_id in &seq.track_lists {
let list = graph
.get(*list_id)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<TrackListBehavior>());
let list = match list {
Some(l) => l,
None => continue,
};
for track_id in &list.tracks {
let track = graph
.get(*track_id)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<TrackBehavior>());
let track = match track {
Some(t) => t,
None => continue,
};
timeline.tracks_mut().append_child(export_track(graph, track, fps));
}
}
timelines.push(timeline);
}
timelines
}
/// Export one track: its kind plus one composable per block, inserting
/// gaps for timeline discontinuities (oak blocks carry absolute
/// positions; OTIO items are placed sequentially).
fn export_track(graph: &oaknode::graph::Graph, track: &TrackBehavior, fps: f64) -> Composable {
let mut out = Track::new(track_kind_str(track.kind));
let mut pos = Rational::new(0, 1);
for block_id in &track.blocks {
let entry = match graph.get(*block_id) {
Some(e) => e,
None => continue,
};
let (kind, block) = classify_block(entry);
let (block_in, block_out, media_in, length) = match block {
Some(core) => (core.in_(), core.out(), core.media_in, core.length()),
None => continue,
};
// A gap before this block, when the timeline position jumped.
if block_in > pos {
let gap_len = block_in - pos;
out.append_child(Composable::Gap(Gap::new(
TimeRange::new(to_rt(pos, fps), to_rt(gap_len, fps)),
"Gap",
)));
}
match kind {
BlockKind::Clip => {
let footage = clip_footage(graph, entry, *block_id);
let mut clip = oakotio::model::Clip::new(clip_name(graph, footage));
let reference = footage
.and_then(|fid| {
graph
.get(fid)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<FootageBehavior>())
.map(|f| f.filename.clone())
})
.map(|fname| media_ref::external(&fname))
.unwrap_or_else(media_ref::missing);
clip.set_media_reference(reference);
clip.set_source_range(TimeRange::new(
to_rt(media_in, fps),
to_rt(length, fps),
));
out.append_child(Composable::Clip(clip));
}
BlockKind::Gap => {
out.append_child(Composable::Gap(Gap::new(
TimeRange::new(to_rt(block_in, fps), to_rt(length, fps)),
"Gap",
)));
}
BlockKind::Transition => {
if let Some(tb) = entry
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<TransitionBlockBehavior>())
{
let mut t = Transition::new("Transition");
t.set_in_offset(to_rt(tb.in_offset, fps));
t.set_out_offset(to_rt(tb.out_offset, fps));
out.append_child(Composable::Transition(t));
}
}
BlockKind::Other => {}
}
pos = block_out;
}
Composable::Track(out)
}
/// Frame rate of a sequence (from its first video stream, or the
/// default).
fn sequence_fps(seq: &SequenceBehavior) -> f64 {
seq.video_params
.first()
.map(|p| p.frame_rate)
.filter(|r| !r.is_null() && r.denominator() != 0)
.map(|r| r.numerator() as f64 / r.denominator() as f64)
.unwrap_or(DEFAULT_FPS)
}
/// The clip's display name: its footage's label, else the footage
/// filename base, else "Clip".
fn clip_name(graph: &oaknode::graph::Graph, footage: Option<NodeId>) -> String {
match footage {
Some(fid) => {
if let Some(e) = graph.get(fid) {
if !e.core.label.is_empty() {
return e.core.label.clone();
}
if let Some(f) = e
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FootageBehavior>())
{
return file_base(&f.filename);
}
}
"Clip".to_string()
}
None => "Clip".to_string(),
}
}
/// Resolve the footage behind a clip block: the block's `footage`
/// field, else the first footage node reachable upstream (mirrors
/// `oaknode_node_find_input_footage`).
fn clip_footage(
graph: &oaknode::graph::Graph,
entry: &NodeEntry,
clip_id: NodeId,
) -> Option<NodeId> {
if let Some(be) = entry
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
{
if let Some(f) = be.footage {
if graph.is_valid(f) {
return Some(f);
}
}
}
let mut visited = HashSet::new();
let mut queue: Vec<NodeId> = graph.upstream(clip_id);
while let Some(id) = queue.pop() {
if !visited.insert(id) {
continue;
}
if let Some(e) = graph.get(id) {
let is_footage = e
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FootageBehavior>())
.is_some();
if is_footage {
return Some(id);
}
}
queue.extend(graph.upstream(id));
}
None
}
/// Classify a block node into its kind plus its block core.
fn classify_block(
entry: &NodeEntry,
) -> (BlockKind, Option<&oaknode::block::BlockCore>) {
entry
.behavior
.as_any()
.and_then(|a| {
if let Some(c) = a.downcast_ref::<ClipBlockBehavior>() {
return Some((BlockKind::Clip, Some(&c.core)));
}
if let Some(g) = a.downcast_ref::<GapBlockBehavior>() {
return Some((BlockKind::Gap, Some(&g.core)));
}
if let Some(t) = a.downcast_ref::<TransitionBlockBehavior>() {
return Some((BlockKind::Transition, Some(&t.core)));
}
None
})
.unwrap_or((BlockKind::Other, None))
}
/// Block node kinds.
enum BlockKind {
Clip,
Gap,
Transition,
Other,
}
/// TrackType -> OTIO kind string.
fn track_kind_str(kind: TrackType) -> &'static str {
match kind {
TrackType::Video => "Video",
TrackType::Audio => "Audio",
TrackType::Subtitle => "Subtitle",
}
}
/// OTIO kind string -> TrackType (anything unknown is subtitle-ish).
fn track_kind_from_str(kind: &str) -> TrackType {
match kind {
"Video" => TrackType::Video,
"Audio" => TrackType::Audio,
_ => TrackType::Subtitle,
}
}
/// Rational -> RationalTime at `fps` (seconds are preserved).
fn to_rt(r: Rational, fps: f64) -> oakotio::model::RationalTime {
oakotio::model::RationalTime::from_rational(r, fps)
}
/// Local path -> `file://` URL (kept simple: no percent-encoding).
fn to_file_uri(path: &str) -> String {
if path.starts_with("file://") {
path.to_string()
} else {
format!("file://{path}")
}
}
/// `file://` URL -> local path (strips the scheme).
fn from_file_uri(url: &str) -> String {
url.strip_prefix("file://").unwrap_or(url).to_string()
}
/// Filename base (last path component, extension dropped).
fn file_base(path: &str) -> String {
let name = path.rsplit('/').next().unwrap_or(path);
match name.rsplit_once('.') {
Some((base, _)) if !base.is_empty() => base.to_string(),
_ => name.to_string(),
}
}
/// Media-reference builders.
mod media_ref {
use super::*;
pub fn external(path: &str) -> MediaReference {
MediaReference::ExternalReference(ExternalReference::new(to_file_uri(path), None))
}
pub fn missing() -> MediaReference {
MediaReference::MissingReference(MissingReference::new())
}
}
// ---------------------------------------------------------------------------
// oakotio -> Project
// ---------------------------------------------------------------------------
/// Import one timeline into `project` as a sequence with its track
/// lists, tracks, and blocks (footage nodes per clip).
fn import_timeline(project: &mut Project, timeline: &Timeline) -> Result<()> {
let (seq_id, lists) = node::create_sequence(&mut project.graph);
if !timeline.name().is_empty() {
if let Some(entry) = project.graph.get_mut(seq_id) {
entry.core.label = timeline.name().to_string();
}
}
let mut track_count_by_list = [0i32, 0, 0];
for composable in timeline.tracks().children() {
let otio_track = match composable.as_track() {
Some(t) => t,
None => continue,
};
let kind = track_kind_from_str(otio_track.kind());
let list_index = match kind {
TrackType::Video => 0,
TrackType::Audio => 1,
TrackType::Subtitle => 2,
};
let list_id = lists[list_index];
// Track node.
let mut track_behavior = TrackBehavior::new(kind);
track_behavior.track_list = Some(list_id);
track_behavior.index = track_count_by_list[list_index];
track_count_by_list[list_index] += 1;
let track_id = project.graph.add_node(NodeCore::new(), Box::new(track_behavior));
// Blocks (positions accumulate; gaps/offsets keep the span).
let mut block_ids = Vec::new();
let mut pos = Rational::new(0, 1);
for child in otio_track.children() {
match child.as_ref() {
Composable::Clip(c) => {
let len = c
.source_range()
.map(|r| r.duration().to_rational())
.filter(|r| !r.is_null())
.unwrap_or(Rational::new(1, 1));
let media_in = c
.source_range()
.map(|r| r.start_time().to_rational())
.unwrap_or(Rational::new(0, 1));
let filename = c
.media_reference()
.and_then(|m| m.as_external_reference())
.map(|e| from_file_uri(e.target_url()));
let (core, mut behavior) = oaknode::block::clip_create();
let block_id = {
let clip = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
.expect("clip_create returns a clip block");
clip.core.range = oakcore_rs::TimeRange::new(pos, pos + len);
clip.core.media_in = media_in;
clip.core.track = Some(track_id);
clip.footage = None;
let id = project.graph.add_node(core, behavior);
if let Some(fname) = filename {
let foot_id = project.graph.add_node(
NodeCore::new(),
Box::new(FootageBehavior::new(&fname)),
);
if let Some(e) = project.graph.get_mut(id) {
if let Some(c) = e
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
{
c.footage = Some(foot_id);
}
}
}
id
};
block_ids.push(block_id);
pos = pos + len;
}
Composable::Gap(g) => {
let len = g
.source_range()
.map(|r| r.duration().to_rational())
.filter(|r| !r.is_null())
.unwrap_or(Rational::new(1, 1));
let (core, mut behavior) = oaknode::block::gap_create();
let block_id = {
let gap = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<GapBlockBehavior>())
.expect("gap_create returns a gap block");
gap.core.range = oakcore_rs::TimeRange::new(pos, pos + len);
gap.core.track = Some(track_id);
project.graph.add_node(core, behavior)
};
block_ids.push(block_id);
pos = pos + len;
}
Composable::Transition(t) => {
// Approximate: a single transition block with its
// offsets; it does not advance the timeline position.
let (core, mut behavior) = oaknode::block::transition_create();
let block_id = {
let tr = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TransitionBlockBehavior>())
.expect("transition_create returns a transition block");
tr.in_offset = t.in_offset().to_rational();
tr.out_offset = t.out_offset().to_rational();
tr.core.range =
oakcore_rs::TimeRange::new(pos, pos + Rational::new(1, 1));
tr.core.track = Some(track_id);
project.graph.add_node(core, behavior)
};
block_ids.push(block_id);
}
_ => {}
}
}
// Wire the blocks into the track, and the track into its list.
if let Some(entry) = project.graph.get_mut(track_id) {
if let Some(tb) = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackBehavior>())
{
tb.blocks = block_ids;
}
}
if let Some(entry) = project.graph.get_mut(list_id) {
if let Some(lb) = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
{
lb.tracks.push(track_id);
}
}
}
Ok(())
}
+111 -18
View File
@@ -14,48 +14,141 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The `.ove` XML file backend. Graph (de)serialization is delegated
//! to oaknode's serializer C ABI (`bridge::node`); this backend owns
//! the file container: version header probe, optional compression
//! (OAKSTORAGE_SAVE_COMPRESS), and byte-exact round-trip behavior
//! pinned by the golden tests (M10 §4).
//! The `.ove` XML file backend. Graph (de)serialization is delegated to
//! oaknode's serializer (`bridge::node`); this backend owns the file
//! container: version-header probe (M10 §2.1 info codes) and the plain
//! XML file I/O.
//!
//! The file format is the current-version `<project version="1">`
//! document produced by `oaknode::serializer::save`. Historical files
//! carry an `<olive version="NNNNNN">` root; the backend probes the
//! root element and reports TOO_OLD / TOO_NEW / UNKNOWN_VERSION per
//! M10 §2.1 before delegating to the serializer.
//!
//! `OAKSTORAGE_SAVE_COMPRESS` is accepted but not implemented: the
//! oaknode serializer emits plain XML only (the OVEC compressed
//! container is a C++ follow-up), so the bit is ignored and the file is
//! written uncompressed. Unknown option bits are ignored too (M10 §2.2).
use crate::backend::LoadResult;
use crate::error::{Error, OAKSTORAGE_OK, OAKSTORAGE_TOO_NEW, OAKSTORAGE_TOO_OLD, OAKSTORAGE_UNKNOWN_VERSION};
use crate::uri::StorageUri;
use oaknode::serializer::XmlRead;
/// The ove-xml backend (`file://` + `.ove`).
pub struct OveXmlBackend;
/// Detected version of a project document.
enum Probe {
/// Current schema (`<project>` root, or `<olive>` at the build
/// version).
Current,
/// Historical `<olive>` root with a known, older version.
Old,
/// Newer than this build can load.
TooNew,
/// Recognized root without a recognizable version.
UnknownVersion,
}
/// Probe the root element of an XML document for the version ladder.
/// `Err(())` means the document has no parseable root (corrupt input —
/// the caller reports E_FORMAT, not a version info code).
fn probe_version(xml: &str) -> std::result::Result<Probe, ()> {
let mut reader = oaknode::serializer::XmlReaderBridge::new(xml).ok_or(())?;
if !reader.next_start_element() {
return Err(());
}
let root = reader.name().to_string();
let version = reader
.attribute("version")
.and_then(|v| v.parse::<u32>().ok());
match root.as_str() {
// Current schema root; the serializer accepts any version attr
// on `<project>` (schema version 1 today).
"project" => Ok(Probe::Current),
"olive" => match version {
None => Ok(Probe::UnknownVersion),
Some(v) if v > oaknode::serializer::CURRENT_VERSION.0 => Ok(Probe::TooNew),
Some(v) if v < oaknode::serializer::CURRENT_VERSION.0 => Ok(Probe::Old),
Some(_) => Ok(Probe::Current),
},
_ => Ok(Probe::UnknownVersion),
}
}
impl OveXmlBackend {
/// Construct.
pub fn new() -> Self {
todo!()
OveXmlBackend
}
}
impl Default for OveXmlBackend {
fn default() -> Self {
Self::new()
}
}
impl crate::backend::StorageBackend for OveXmlBackend {
fn name(&self) -> &'static str {
todo!()
"ove-xml"
}
fn uri_scheme(&self) -> &'static str {
todo!()
"file"
}
fn can_handle(&self, uri: &crate::uri::StorageUri) -> bool {
todo!()
fn can_handle(&self, uri: &StorageUri) -> bool {
uri.extension().map(|e| e == "ove").unwrap_or(false)
}
fn load(
&self,
uri: &crate::uri::StorageUri,
) -> crate::error::Result<crate::handle::CHandle> {
todo!()
fn load(&self, uri: &StorageUri) -> crate::error::Result<LoadResult> {
let path = uri
.local_path()
.ok_or(Error::Invalid)?
.to_string();
let xml = std::fs::read_to_string(&path).map_err(|e| Error::Io(e.to_string()))?;
let probe = probe_version(&xml).map_err(|_| {
Error::Format(format!("'{}' is not a parseable project document", path))
})?;
match probe {
Probe::TooNew => return Ok(LoadResult::info_only(OAKSTORAGE_TOO_NEW)),
Probe::UnknownVersion => {
return Ok(LoadResult::info_only(OAKSTORAGE_UNKNOWN_VERSION));
}
Probe::Current | Probe::Old => {}
}
let project = crate::bridge::node::serializer_load(&xml)?;
let handle = crate::bridge::node::make_project_owned(project);
let version_info = if matches!(probe, Probe::Old) {
OAKSTORAGE_TOO_OLD
} else {
OAKSTORAGE_OK
};
Ok(LoadResult::with_info(handle, version_info))
}
fn save(
&self,
project: crate::handle::CHandle,
uri: &crate::uri::StorageUri,
options: u32,
uri: &StorageUri,
_options: u32,
) -> crate::error::Result<()> {
todo!()
let path = uri
.local_path()
.ok_or(Error::Invalid)?
.to_string();
let arc = unsafe { crate::bridge::node::project_arc(&project)? };
let xml = {
let guard = arc
.lock()
.map_err(|_| Error::State)?;
crate::bridge::node::serializer_save(&guard)?
};
std::fs::write(&path, xml).map_err(|e| Error::Io(e.to_string()))?;
Ok(())
}
}
+148 -15
View File
@@ -14,24 +14,157 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! oaknode C ABI imports (project + serializer family; signatures
//! mirror include/node/*.h verbatim).
//! oaknode calls — direct Rust calls into the oaknode crate (single-lib
//! unification, see `docs/zh/plans/riir/single-lib.md`).
//!
//! The ove-xml backend needs oaknode's project + serializer families
//! (`oaknode_project_init` / `oaknode_serializer_save_to_file` /
//! `load_from_file` in the C++ world). With direct calls, graph
//! (de)serialization stays oaknode's own serializer (`load`/`save`),
//! and this bridge only boxes the resulting `Arc<Mutex<Project>>` into
//! the canonical `CHandle` form the public C API moves around.
//!
//! A project handle boxed here is layout-identical to one produced by
//! `oaknode_project_init`: both box `Arc<Mutex<Project>>` behind the
//! shared `oakcore_rs::handle::CHandle`, so `oakstorage_save` can
//! consume an oaknode-created handle and `oakstorage_project_take_*`
//! returns one the caller can hand back to oaknode functions.
use std::ffi::{c_char, c_int};
use std::sync::{Arc, Mutex};
use crate::error::Result;
use crate::handle::CHandle;
extern "C" {
/// `oaknode_project_init`.
pub fn oaknode_project_init() -> CHandle;
/// `oaknode_project_free`.
pub fn oaknode_project_free(project: *mut CHandle);
/// `oaknode_project_load_from_data` (XML text → project).
pub fn oaknode_project_load_from_data(data: *const c_char) -> CHandle;
/// `oaknode_project_save_to_data` (project → XML, two-stage string).
pub fn oaknode_project_save_to_data(
project: CHandle,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// The project payload boxed by project handles.
pub type ProjectArc = Arc<Mutex<oaknode::project::Project>>;
/// New empty project, boxed as an owned handle (refcount 1). The
/// caller owns it (free with [`project_free`] / `oaknode_project_free`).
pub fn project_init() -> CHandle {
oaknode::handle::make_owned(oaknode::project::Project::new())
}
/// Free a project handle (`NULL`/empty no-op).
pub fn project_free(project: *mut CHandle) {
if project.is_null() || unsafe { (*project).ctx.is_null() } {
return;
}
let h = unsafe { (*project).clone() };
if let Some(f) = h.release {
unsafe { f(h.ctx) };
}
unsafe { (*project).ctx = std::ptr::null_mut() };
}
/// Box an existing project as an owned handle (refcount 1).
pub fn make_project_owned(project: ProjectArc) -> CHandle {
oaknode::handle::make_owned(project)
}
/// Read the boxed project of a project handle.
///
/// # Safety
/// The handle must box `Arc<Mutex<Project>>` (created by this bridge,
/// `oaknode_project_init`, or `oakstorage_project_take_project`).
pub unsafe fn project_arc(h: &CHandle) -> Result<ProjectArc> {
unsafe { oaknode::handle::get::<ProjectArc>(h) }
.cloned()
.ok_or(crate::error::Error::Invalid)
}
/// Load a project from XML text via the oaknode serializer.
pub fn serializer_load(xml: &str) -> Result<ProjectArc> {
oaknode::serializer::load(xml).map_err(|e| crate::error::Error::Format(e.to_string()))
}
/// Serialize a project to XML text via the oaknode serializer.
pub fn serializer_save(project: &oaknode::project::Project) -> Result<String> {
oaknode::serializer::save(project).map_err(|e| crate::error::Error::Format(e.to_string()))
}
/// Create a sequence node with its default track lists (video, audio,
/// subtitle) in `graph`. Returns `(sequence_id, [video, audio,
/// subtitle] list ids)`.
///
/// Mirrors the private `append_default_track_lists` wiring of
/// `oaknode_sequence_create` (ffi.rs) so the otio backend can import
/// timelines into real projects; shared with tests through this bridge.
pub fn create_sequence(
graph: &mut oaknode::graph::Graph,
) -> (oaknode::id::NodeId, Vec<oaknode::id::NodeId>) {
use oaknode::input::Input;
use oaknode::node::NodeCore;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackListBehavior, TrackType};
use oaknode::value::{NodeValue, ValueType};
let mut seq = SequenceBehavior::new();
seq.set_default_parameters();
let mut core = NodeCore::new();
core.add_input(Input::new(
oaknode::sequence::TEXTURE_INPUT,
ValueType::Texture,
NodeValue::None,
));
core.add_input(Input::new(
oaknode::sequence::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 — the C++
// `Sequence::k_track_input_format` convention).
for base in 0..3 {
let mut track_input = Input::new(
&oaknode::sequence::TRACK_INPUT_FORMAT.replace("%1", &base.to_string()),
ValueType::None,
NodeValue::None,
);
track_input.flags |= oaknode::input::flags::ARRAY;
core.add_input(track_input);
}
let seq_id = graph.add_node(core, Box::new(seq));
let mut lists = Vec::new();
let mut base = 0i32;
for kind in [TrackType::Video, TrackType::Audio, TrackType::Subtitle] {
let mut behavior = TrackListBehavior::new(kind);
behavior.sequence = Some(seq_id);
behavior.array_base = base;
let id = graph.add_node(NodeCore::new(), Box::new(behavior));
lists.push(id);
base += 1;
}
// Record the lists on the sequence behavior.
if let Some(entry) = graph.get_mut(seq_id) {
if let Some(s) = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
{
s.track_lists = lists.clone();
}
}
(seq_id, lists)
}
/// Two-stage C string copy helper: returns the required size including
/// the NUL; writes up to `buf_size - 1` bytes plus NUL when
/// `buf_size > 0` (`// CPP-PARITY: src/node/c_api/valueconvert.h`
/// `copy_string`).
///
/// # Safety
/// `buf`/`buf_size` must describe a valid writable buffer (or `buf` may
/// be null).
pub unsafe fn copy_string_out(value: &str, buf: *mut c_char, buf_size: c_int) -> c_int {
let required = value.len() + 1;
if !buf.is_null() && buf_size > 0 {
let copy_len = value.len().min(buf_size as usize - 1);
unsafe {
std::ptr::copy_nonoverlapping(value.as_ptr() as *const c_char, buf, copy_len);
*buf.add(copy_len) = 0;
}
}
required as c_int
}
+302 -15
View File
@@ -18,19 +18,86 @@
//! (`oakstorage_probe/open/save/project_free/project_take_project/
//! project_project/project_uri/last_error/debug_alive_count` and the
//! backend vtable registration pair).
//!
//! `oakstorage_open` returns a session handle (alive-counted). The
//! project inside is an `OakNodeProject*`-compatible handle boxed via
//! the shared `oakcore_rs::handle::CHandle`, so `take_project` hands
//! back something `oaknode_*` functions accept, and `save` accepts
//! handles produced by `oaknode_project_init`.
use std::ffi::{c_char, c_int, c_uint, c_void};
use std::cell::RefCell;
use std::ffi::{c_char, c_int, c_uint, CStr, CString};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use crate::backend::StorageBackend;
use crate::bridge::node;
use crate::error::{Error, OAKSTORAGE_E_FAILED, OAKSTORAGE_OK, Result};
use crate::handle::CHandle;
use crate::session::Session;
use crate::uri::StorageUri;
/// Save options bitmask: compress the ove-xml container.
/// Save options bitmask: compress the ove-xml container. Accepted but
/// not implemented by the built-in backends (the oaknode serializer
/// emits plain XML only); unknown bits are ignored (M10 §2.2).
pub const OAKSTORAGE_SAVE_COMPRESS: c_uint = 0x1;
/// Live session count (`oakstorage_debug_alive_count`).
static ALIVE: AtomicI32 = AtomicI32::new(0);
// Per-thread last-error detail (`oakstorage_last_error`).
thread_local! {
static LAST_ERROR: RefCell<String> = const { RefCell::new(String::new()) };
}
/// Record the thread's last error detail.
fn set_last_error(msg: &str) {
LAST_ERROR.with(|e| *e.borrow_mut() = msg.to_string());
}
/// Read the thread's last error detail.
fn take_last_error() -> String {
LAST_ERROR.with(|e| e.borrow().clone())
}
/// Release for session handles (alive-counted; the session's Drop
/// releases the held project handle).
unsafe extern "C" fn session_release(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *mut crate::handle::RefBox<Session>;
if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 {
drop(Box::from_raw(rb));
ALIVE.fetch_sub(1, Ordering::Relaxed);
}
}
}
/// `oakstorage_probe`: which backend claims this URI (two-stage
/// string; negative = E_NO_BACKEND).
#[no_mangle]
pub unsafe extern "C" fn oakstorage_probe(uri: *const c_char, buf: *mut c_char, buf_size: c_int) -> c_int {
todo!()
pub unsafe extern "C" fn oakstorage_probe(
uri: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
let uri_str = unsafe { cstr(uri) }.map(str::to_string);
match catch_unwind(AssertUnwindSafe(|| {
let s = uri_str.as_deref().ok_or(Error::Invalid)?;
let parsed = StorageUri::parse(s)?;
let backend = crate::registry::Registry::global().resolve(&parsed)?;
Ok::<String, Error>(backend.name().to_string())
})) {
Ok(Ok(name)) => unsafe { node::copy_string_out(&name, buf, buf_size) },
Ok(Err(e)) => {
set_last_error(&e.to_string());
e.code()
}
Err(_) => {
set_last_error("storage: panic in oakstorage_probe");
OAKSTORAGE_E_FAILED
}
}
}
/// `oakstorage_open`: open a project session by URI (owned handle;
@@ -38,7 +105,47 @@ pub unsafe extern "C" fn oakstorage_probe(uri: *const c_char, buf: *mut c_char,
/// version info codes).
#[no_mangle]
pub unsafe extern "C" fn oakstorage_open(uri: *const c_char, result_code: *mut c_int) -> CHandle {
todo!()
let uri_str = unsafe { cstr(uri) }.map(str::to_string);
match catch_unwind(AssertUnwindSafe(|| open_inner(uri_str.as_deref()))) {
Ok(Ok((handle, info))) => {
if !result_code.is_null() {
unsafe { *result_code = info };
}
handle
}
Ok(Err(e)) => {
set_last_error(&e.to_string());
if !result_code.is_null() {
unsafe { *result_code = e.code() };
}
CHandle::null()
}
Err(_) => {
set_last_error("storage: panic in oakstorage_open");
if !result_code.is_null() {
unsafe { *result_code = OAKSTORAGE_E_FAILED };
}
CHandle::null()
}
}
}
/// Open a session: parse + resolve + backend load, then wrap the
/// project in an alive-counted session handle. The returned info code
/// is the backend's version verdict (OK or a positive info code).
fn open_inner(uri: Option<&str>) -> Result<(CHandle, i32)> {
let s = uri.ok_or(Error::Invalid)?;
let parsed = StorageUri::parse(s)?;
let backend = crate::registry::Registry::global().resolve(&parsed)?;
let loaded = backend.load(&parsed)?;
let info = loaded.version_info;
if loaded.project.is_null() {
// Version probing declined to load (TOO_NEW / UNKNOWN_VERSION).
return Ok((CHandle::null(), info));
}
ALIVE.fetch_add(1, Ordering::Relaxed);
let session = Session::new(parsed, loaded.project);
Ok((crate::handle::make_owned_with(session, session_release), info))
}
/// `oakstorage_save`: save a project to a URI (options bitmask;
@@ -49,27 +156,78 @@ pub unsafe extern "C" fn oakstorage_save(
uri: *const c_char,
options: c_uint,
) -> c_int {
todo!()
let uri_str = unsafe { cstr(uri) }.map(str::to_string);
match catch_unwind(AssertUnwindSafe(|| {
let s = uri_str.as_deref().ok_or(Error::Invalid)?;
let parsed = StorageUri::parse(s)?;
let backend = crate::registry::Registry::global().resolve(&parsed)?;
backend.save(project, &parsed, options)
})) {
Ok(Ok(())) => OAKSTORAGE_OK,
Ok(Err(e)) => {
set_last_error(&e.to_string());
e.code()
}
Err(_) => {
set_last_error("storage: panic in oakstorage_save");
OAKSTORAGE_E_FAILED
}
}
}
/// `oakstorage_project_free` (NULL/empty no-op).
#[no_mangle]
pub unsafe extern "C" fn oakstorage_project_free(session: *mut CHandle) {
todo!()
crate::handle::guard_void(|| unsafe {
if session.is_null() || (*session).ctx.is_null() {
return;
}
let h = (*session).clone();
if let Some(f) = h.release {
f(h.ctx);
}
(*session).ctx = std::ptr::null_mut();
});
}
/// `oakstorage_project_take_project`: ownership transfer out of the
/// session (the session becomes an empty shell; still free it).
#[no_mangle]
pub unsafe extern "C" fn oakstorage_project_take_project(session: CHandle) -> CHandle {
todo!()
match catch_unwind(AssertUnwindSafe(|| unsafe { session_take(session) })) {
Ok(Ok(h)) => h,
Ok(Err(_)) | Err(_) => CHandle::null(),
}
}
/// Mutate the boxed session to take its project out (empty after an
/// earlier take — then an empty handle, not an error).
unsafe fn session_take(session: CHandle) -> Result<CHandle> {
let rb = session.ctx as *mut crate::handle::RefBox<Session>;
if rb.is_null() {
return Err(Error::Invalid);
}
Ok(unsafe { (*rb).value.take() }.unwrap_or_else(CHandle::null))
}
/// `oakstorage_project_project`: borrowed project handle (empty after
/// take).
/// take). A fresh box over a clone of the project Arc, so releasing it
/// (or not) cannot disturb the session's own handle.
#[no_mangle]
pub unsafe extern "C" fn oakstorage_project_project(session: CHandle) -> CHandle {
todo!()
match catch_unwind(AssertUnwindSafe(|| -> crate::error::Result<CHandle> {
unsafe {
let sess = crate::handle::get::<Session>(&session).ok_or(Error::Invalid)?;
let ph = sess.project().ok_or(Error::State)?;
let arc = oaknode::handle::get::<node::ProjectArc>(ph)
.ok_or(Error::Invalid)?
.clone();
Ok(oaknode::handle::make_owned(arc))
}
})) {
Ok(Ok(h)) => h,
Ok(Err(_)) | Err(_) => CHandle::null(),
}
}
/// `oakstorage_project_uri` (two-stage string).
@@ -79,22 +237,31 @@ pub unsafe extern "C" fn oakstorage_project_uri(
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
todo!()
let uri_str = match unsafe { crate::handle::get::<Session>(&session) } {
Some(s) => s.uri().to_uri_string(),
None => return crate::error::OAKSTORAGE_E_INVALID,
};
unsafe { node::copy_string_out(&uri_str, buf, buf_size) }
}
/// `oakstorage_last_error` (two-stage string; per-thread last error
/// detail).
#[no_mangle]
pub unsafe extern "C" fn oakstorage_last_error(buf: *mut c_char, buf_size: c_int) -> c_int {
todo!()
let msg = take_last_error();
unsafe { node::copy_string_out(&msg, buf, buf_size) }
}
/// `oakstorage_debug_alive_count` (leak assertions in tests).
#[no_mangle]
pub unsafe extern "C" fn oakstorage_debug_alive_count() -> c_int {
todo!()
ALIVE.load(Ordering::Relaxed)
}
// ---------------------------------------------------------------------
// Foreign (C-side) backend vtable registration — M10 §2.3
// ---------------------------------------------------------------------
/// C-side backend vtable (M10 §2.3; the layout is the C header's
/// struct — implementer copies it field-for-field).
#[repr(C)]
@@ -126,6 +293,93 @@ pub struct OakStorageBackendVtable {
>,
}
/// A backend registered from C: every call funnels through the vtable
/// function pointers (the vtable itself is not copied — the caller
/// guarantees it outlives unregistration).
struct ForeignBackend {
name: String,
uri_scheme: String,
can_handle: unsafe extern "C" fn(uri: *const c_char) -> c_int,
load: unsafe extern "C" fn(
uri: *const c_char,
result_code: *mut c_int,
err_buf: *mut c_char,
err_buf_size: c_int,
) -> CHandle,
save: unsafe extern "C" fn(
project: CHandle,
uri: *const c_char,
options: c_uint,
err_buf: *mut c_char,
err_buf_size: c_int,
) -> c_int,
}
impl StorageBackend for ForeignBackend {
fn name(&self) -> &str {
&self.name
}
fn uri_scheme(&self) -> &str {
&self.uri_scheme
}
fn can_handle(&self, uri: &StorageUri) -> bool {
let c = match CString::new(uri.to_uri_string()) {
Ok(c) => c,
Err(_) => return false,
};
let claimed = unsafe { (self.can_handle)(c.as_ptr()) };
claimed != 0
}
fn load(&self, uri: &StorageUri) -> Result<crate::backend::LoadResult> {
let c = CString::new(uri.to_uri_string()).map_err(|_| Error::Invalid)?;
let mut result_code: c_int = OAKSTORAGE_OK;
let handle = unsafe {
(self.load)(c.as_ptr(), &mut result_code, std::ptr::null_mut(), 0)
};
if !handle.is_null() {
Ok(crate::backend::LoadResult::with_info(handle, result_code))
} else if result_code > 0 {
// Version info code without a project.
Ok(crate::backend::LoadResult::info_only(result_code))
} else {
Err(code_to_error(result_code))
}
}
fn save(
&self,
project: CHandle,
uri: &StorageUri,
options: u32,
) -> Result<()> {
let c = CString::new(uri.to_uri_string()).map_err(|_| Error::Invalid)?;
let rc = unsafe { (self.save)(project, c.as_ptr(), options, std::ptr::null_mut(), 0) };
if rc == OAKSTORAGE_OK {
Ok(())
} else {
Err(code_to_error(rc))
}
}
}
/// Map a foreign backend's return code to the crate error.
fn code_to_error(code: c_int) -> Error {
match code {
crate::error::OAKSTORAGE_E_INVALID => Error::Invalid,
crate::error::OAKSTORAGE_E_STATE => Error::State,
crate::error::OAKSTORAGE_E_NOT_FOUND => Error::NotFound,
crate::error::OAKSTORAGE_E_FAILED => Error::Failed("foreign backend failed".to_string()),
crate::error::OAKSTORAGE_E_NO_BACKEND => Error::NoBackend,
crate::error::OAKSTORAGE_E_FORMAT => Error::Format("foreign backend format error".to_string()),
crate::error::OAKSTORAGE_E_IO => Error::Io("foreign backend I/O error".to_string()),
crate::error::OAKSTORAGE_E_NOMEM => Error::NoMem,
_ => Error::Failed(format!("foreign backend error code {code}")),
}
}
/// `oakstorage_backend_register`: register a foreign backend (the
/// vtable is not copied — the caller guarantees it outlives
/// unregistration).
@@ -133,11 +387,44 @@ pub struct OakStorageBackendVtable {
pub unsafe extern "C" fn oakstorage_backend_register(
backend: *const OakStorageBackendVtable,
) -> c_int {
todo!()
crate::handle::guard(|| unsafe {
if backend.is_null() {
return Err(Error::Invalid);
}
let v = &*backend;
let name = cstr(v.name).ok_or(Error::Invalid)?.to_string();
let uri_scheme = cstr(v.uri_scheme).ok_or(Error::Invalid)?.to_string();
let can_handle = v.can_handle.ok_or(Error::Invalid)?;
let load = v.load.ok_or(Error::Invalid)?;
let save = v.save.ok_or(Error::Invalid)?;
let foreign = ForeignBackend {
name,
uri_scheme,
can_handle,
load,
save,
};
crate::registry::Registry::global().register(Arc::new(foreign))
})
}
/// `oakstorage_backend_unregister`.
#[no_mangle]
pub unsafe extern "C" fn oakstorage_backend_unregister(name: *const c_char) -> c_int {
todo!()
crate::handle::guard(|| unsafe {
let name = cstr(name).ok_or(Error::Invalid)?;
crate::registry::Registry::global().unregister(name)
})
}
/// Safe read of a NUL-terminated C string argument.
///
/// # Safety
/// `p` must be a valid NUL-terminated C string for the returned
/// reference's lifetime.
unsafe fn cstr<'a>(p: *const c_char) -> Option<&'a str> {
if p.is_null() {
return None;
}
unsafe { CStr::from_ptr(p) }.to_str().ok()
}
+119 -30
View File
@@ -14,11 +14,24 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Refcounted-handle scaffolding (same per-module pattern as the other
//! crates; duplicated on purpose — handle function pointers must run
//! code from the creating DLL).
//! Refcounted-handle scaffolding (same pattern as the other crates;
//! duplicated on purpose — handle function pointers must run code from
//! the creating DLL).
//!
//! `CHandle` is the canonical shared ABI value-handle type from
//! `oakcore-rs` (single-lib unification, see
//! `docs/zh/plans/riir/single-lib.md`), so a handle returned by
//! `oakstorage_*` is structurally interchangeable with one created by
//! `oaknode_*`: `oakstorage_save` consumes an `OakNodeProject*` handle
//! and `oakstorage_project_take_project` hands one back.
use std::sync::atomic::AtomicU32;
use std::any::Any;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicU32, Ordering};
pub use oakcore_rs::handle::CHandle;
use crate::error::OAKSTORAGE_E_FAILED;
/// ABI version stamped into every handle.
pub const OAKSTORAGE_ABI_VERSION: u32 = 1;
@@ -31,58 +44,134 @@ pub struct RefBox<T: ?Sized> {
pub value: T,
}
/// `#[repr(C)]` mirror of the public handle structs.
#[repr(C)]
pub struct CHandle {
/// Opaque box pointer.
pub ctx: *mut std::ffi::c_void,
/// Atomic increment.
pub addref: Option<unsafe extern "C" fn(*mut std::ffi::c_void)>,
/// Atomic decrement; destroys at zero.
pub release: Option<unsafe extern "C" fn(*mut std::ffi::c_void)>,
/// ABI version.
pub abi_version: u32,
/// addref implementation: atomic +1 (owned and borrowed handles alike —
/// a borrow only extends the box's life, not the borrowed object's).
unsafe extern "C" fn refbox_addref<T: Any + Send>(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *const RefBox<T>;
// The caller guarantees the handle is live for the borrow period.
(*rb).refs.fetch_add(1, Ordering::Relaxed);
}
}
impl CHandle {
/// The empty handle.
pub fn null() -> Self {
todo!()
/// release implementation (owned): atomic -1; at zero, reclaim the box
/// and destroy the contained value.
unsafe extern "C" fn refbox_release_owned<T: Any + Send>(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *mut RefBox<T>;
// AcqRel: the zeroing side sees every write that preceded the
// last reference (including the state the destructor needs).
if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 {
drop(Box::from_raw(rb));
}
}
}
/// release implementation (borrowed, from [`make_borrowed`]): at zero,
/// free only the box memory and forget the contained value — ownership
/// stays with the borrowing side.
unsafe extern "C" fn refbox_release_borrowed<T: Any + Send>(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *mut RefBox<T>;
if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 {
// Partial move: move the value out of the temporary Box (its
// destructor then only frees the allocation); `forget` skips
// the value's destructor (double-free defense).
std::mem::forget((Box::from_raw(rb)).value);
}
}
}
/// Owned handle with count 1; empty on allocation failure.
pub fn make_owned<T: Send + 'static>(value: T) -> CHandle {
todo!()
pub fn make_owned<T: Any + Send>(value: T) -> CHandle {
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
value,
}));
CHandle {
ctx: rb as *mut std::ffi::c_void,
addref: Some(refbox_addref::<T>),
release: Some(refbox_release_owned::<T>),
abi_version: OAKSTORAGE_ABI_VERSION,
}
}
/// Borrowed handle for an object owned elsewhere.
/// Owned handle with count 1 and a caller-provided release routine
/// (used by the ffi layer's alive-counted session boxes, whose release
/// must also update the debug counter).
pub fn make_owned_with<T: Any + Send>(
value: T,
release: unsafe extern "C" fn(*mut std::ffi::c_void),
) -> CHandle {
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
value,
}));
CHandle {
ctx: rb as *mut std::ffi::c_void,
addref: Some(refbox_addref::<T>),
release: Some(release),
abi_version: OAKSTORAGE_ABI_VERSION,
}
}
/// Borrowed handle for an object owned elsewhere (release frees only
/// the box).
///
/// Semantics: bitwise copy ("borrowed copy"); the borrowed object's
/// destructor is entirely the caller's responsibility — the box never
/// touches it.
///
/// # Safety
/// Caller guarantees `ptr` outlives every derived handle.
pub unsafe fn make_borrowed<T: Send + 'static>(ptr: *mut T) -> CHandle {
todo!()
/// Caller guarantees `ptr` outlives every derived handle, and that its
/// value is not moved or destroyed for the borrow's lifetime.
pub unsafe fn make_borrowed<T: Any + Send>(ptr: *mut T) -> CHandle {
if ptr.is_null() {
return CHandle::null();
}
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
value: unsafe { std::ptr::read(ptr) },
}));
CHandle {
ctx: rb as *mut std::ffi::c_void,
addref: Some(refbox_addref::<T>),
release: Some(refbox_release_borrowed::<T>),
abi_version: OAKSTORAGE_ABI_VERSION,
}
}
/// Typed view into a handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get<T: 'static>(h: &CHandle) -> Option<&T> {
todo!()
pub unsafe fn get<T: Any>(h: &CHandle) -> Option<&T> {
if h.ctx.is_null() {
return None;
}
unsafe { Some(&(*(h.ctx as *const RefBox<T>)).value) }
}
/// Panic-catching FFI wrapper for i32-returning exports.
///
/// Panics map to [`OAKSTORAGE_E_FAILED`].
pub fn guard<F: FnOnce() -> crate::error::Result<()>>(f: F) -> i32 {
todo!()
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(())) => crate::error::OAKSTORAGE_OK,
Ok(Err(e)) => e.code(),
Err(_) => OAKSTORAGE_E_FAILED,
}
}
/// Panic-catching FFI wrapper for handle-returning exports.
pub fn guard_handle<F: FnOnce() -> crate::error::Result<CHandle>>(f: F) -> CHandle {
todo!()
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(h)) => h,
Ok(Err(_)) | Err(_) => CHandle::null(),
}
}
/// Panic-catching FFI wrapper for void exports.
pub fn guard_void<F: FnOnce()>(f: F) {
todo!()
let _ = catch_unwind(AssertUnwindSafe(f));
}
+168 -9
View File
@@ -15,10 +15,16 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The backend registry: registration, arbitration, lookup.
//!
//! The process-wide registry is pre-populated with the built-in
//! backends (ove-xml, otio) at first access; foreign backends (the
//! future database backend, or a test mock) register through
//! [`Registry::register`] or the C vtable pair in `ffi.rs`.
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, OnceLock};
use crate::backend::StorageBackend;
use crate::error::{Error, Result};
use crate::uri::StorageUri;
/// Process-wide backend registry (built-ins pre-registered).
@@ -27,24 +33,177 @@ pub struct Registry {
}
impl Registry {
/// Global registry.
/// Global registry; built-ins are installed on first access.
pub fn global() -> &'static Registry {
todo!()
static REGISTRY: OnceLock<Registry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let registry = Registry {
backends: Mutex::new(Vec::new()),
};
{
let mut guard = lock(&registry.backends);
for backend in crate::backends::builtins() {
guard.push(backend);
}
}
registry
})
}
/// Register a backend (registration order = arbitration order
/// within a scheme). Duplicate names are rejected with E_STATE.
pub fn register(&self, backend: Arc<dyn StorageBackend>) -> crate::error::Result<()> {
todo!()
pub fn register(&self, backend: Arc<dyn StorageBackend>) -> Result<()> {
let mut guard = lock(&self.backends);
if guard.iter().any(|b| b.name() == backend.name()) {
return Err(Error::State);
}
guard.push(backend);
Ok(())
}
/// Unregister by name; unknown name is E_NOT_FOUND.
pub fn unregister(&self, name: &str) -> crate::error::Result<()> {
todo!()
pub fn unregister(&self, name: &str) -> Result<()> {
let mut guard = lock(&self.backends);
let before = guard.len();
guard.retain(|b| b.name() != name);
if guard.len() == before {
Err(Error::NotFound)
} else {
Ok(())
}
}
/// First backend claiming `uri` (E_NO_BACKEND when none).
pub fn resolve(&self, uri: &StorageUri) -> crate::error::Result<Arc<dyn StorageBackend>> {
todo!()
pub fn resolve(&self, uri: &StorageUri) -> Result<Arc<dyn StorageBackend>> {
let guard = lock(&self.backends);
guard
.iter()
.find(|b| b.can_handle(uri))
.cloned()
.ok_or(Error::NoBackend)
}
}
/// Lock a mutex, tolerating poisoning (a panic inside this crate must
/// not cascade into every later call).
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::{LoadResult, StorageBackend};
use crate::handle::CHandle;
/// A trivial in-crate backend for registry unit tests.
struct MemBackend;
impl StorageBackend for MemBackend {
fn name(&self) -> &'static str {
"mem-test"
}
fn uri_scheme(&self) -> &'static str {
"mem"
}
fn can_handle(&self, uri: &StorageUri) -> bool {
uri.scheme == "mem"
}
fn load(&self, _uri: &StorageUri) -> Result<LoadResult> {
Ok(LoadResult::success(CHandle::null()))
}
fn save(&self, _project: CHandle, _uri: &StorageUri, _options: u32) -> Result<()> {
Ok(())
}
}
#[test]
fn builtins_are_preregistered() {
let names: Vec<String> = {
let guard = lock(&Registry::global().backends);
guard.iter().map(|b| b.name().to_string()).collect()
};
assert!(names.iter().any(|n| n == "ove-xml"), "builtins: {names:?}");
assert!(names.iter().any(|n| n == "otio"), "builtins: {names:?}");
}
#[test]
fn register_unregister_resolve() {
let registry = Registry {
backends: Mutex::new(Vec::new()),
};
let backend = Arc::new(MemBackend);
assert!(registry.resolve(&StorageUri::parse("mem://x").unwrap()).is_err());
registry.register(backend).unwrap();
// Duplicate name rejected.
assert!(registry.register(Arc::new(MemBackend)).is_err());
assert!(
registry
.resolve(&StorageUri::parse("mem://x").unwrap())
.is_ok()
);
// Unknown scheme still unresolvable.
assert!(
registry
.resolve(&StorageUri::parse("file:///a.ove").unwrap())
.is_err()
);
// Unregister flips it back to E_NO_BACKEND; unknown names E_NOT_FOUND.
registry.unregister("mem-test").unwrap();
assert!(registry.unregister("mem-test").is_err());
assert!(
registry
.resolve(&StorageUri::parse("mem://x").unwrap())
.is_err()
);
}
#[test]
fn resolve_is_first_registered_winner() {
let registry = Registry {
backends: Mutex::new(Vec::new()),
};
struct First;
struct Second;
impl StorageBackend for First {
fn name(&self) -> &'static str {
"first"
}
fn uri_scheme(&self) -> &'static str {
"x"
}
fn can_handle(&self, _uri: &StorageUri) -> bool {
true
}
fn load(&self, _uri: &StorageUri) -> Result<LoadResult> {
Ok(LoadResult::success(CHandle::null()))
}
fn save(&self, _project: CHandle, _uri: &StorageUri, _options: u32) -> Result<()> {
Ok(())
}
}
impl StorageBackend for Second {
fn name(&self) -> &'static str {
"second"
}
fn uri_scheme(&self) -> &'static str {
"x"
}
fn can_handle(&self, _uri: &StorageUri) -> bool {
true
}
fn load(&self, _uri: &StorageUri) -> Result<LoadResult> {
Ok(LoadResult::success(CHandle::null()))
}
fn save(&self, _project: CHandle, _uri: &StorageUri, _options: u32) -> Result<()> {
Ok(())
}
}
registry.register(Arc::new(First)).unwrap();
registry.register(Arc::new(Second)).unwrap();
let backend = registry
.resolve(&StorageUri::parse("x://a").unwrap())
.unwrap();
assert_eq!(backend.name(), "first");
}
}
+22 -5
View File
@@ -32,21 +32,38 @@ pub struct Session {
impl Session {
/// Wrap a freshly loaded project.
pub fn new(uri: StorageUri, project: CHandle) -> Self {
todo!()
Session {
uri,
project: Some(project),
}
}
/// Source URI.
pub fn uri(&self) -> &StorageUri {
todo!()
&self.uri
}
/// Borrowed project handle (None after take).
pub fn project(&self) -> Option<&CHandle> {
todo!()
self.project.as_ref()
}
/// Transfer the project out (C++ take_project semantics).
/// Transfer the project out (C++ take_project semantics); the
/// session becomes an empty shell, and the caller owns the returned
/// handle (release it with `oaknode_project_free`).
pub fn take(&mut self) -> Option<CHandle> {
todo!()
self.project.take()
}
}
impl Drop for Session {
fn drop(&mut self) {
// Release the still-held project handle (the `take` path already
// removed it).
if let Some(h) = self.project.take() {
if let Some(f) = h.release {
unsafe { f(h.ctx) };
}
}
}
}
+150 -7
View File
@@ -18,6 +18,14 @@
//! paths — every entry point takes a URI (M10 §2.2).
/// A parsed storage URI.
///
/// Two shapes:
/// - `scheme://body` — explicit scheme URIs (`file:///abs/path.ove`,
/// `oakdb://user:pass@host/db`). The body is the scheme-specific part
/// (for `file` it is the path, for `oakdb` the connection string).
/// - a bare path (`/abs/path.ove`, `rel/path.ove`) — normalized to a
/// `file://` URI so C++ callers that pass plain filenames keep working
/// (M10 §2.2 note).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StorageUri {
/// Scheme ("file", "oakdb", …).
@@ -28,19 +36,154 @@ pub struct StorageUri {
}
impl StorageUri {
/// Parse a URI string; bare paths are normalized to file:// URIs
/// (C++ callers pass plain paths — M10 §2.2 note).
/// Parse a URI string; bare paths are normalized to file:// URIs.
///
/// `Err(Error::Invalid)` for an empty string. A scheme is recognized
/// only in the `scheme://` form (`[a-zA-Z][a-zA-Z0-9+.-]*` per RFC
/// 3986 §3.1); anything else is a bare path.
pub fn parse(s: &str) -> crate::error::Result<StorageUri> {
todo!()
if s.is_empty() {
return Err(crate::error::Error::Invalid);
}
match s.find("://") {
Some(pos) if is_scheme(&s[..pos]) => Ok(StorageUri {
scheme: s[..pos].to_string(),
body: s[pos + 3..].to_string(),
}),
_ => Ok(StorageUri {
scheme: "file".to_string(),
body: s.to_string(),
}),
}
}
/// File extension (lowercased, without dot) for file:// URIs.
/// File extension (lowercased, without dot) for file:// URIs;
/// `None` for other schemes or a path without an extension.
pub fn extension(&self) -> Option<String> {
todo!()
if self.scheme != "file" {
return None;
}
let name = self.body.rsplit('/').next().unwrap_or(&self.body);
let base = match name.rsplit_once('.') {
// A stem-less name (dotfile) has no extension.
Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => ext,
_ => "",
};
if base.is_empty() {
None
} else {
Some(base.to_lowercase())
}
}
/// Back to string form.
/// The local filesystem path for file:// URIs (`None` otherwise).
pub fn local_path(&self) -> Option<&str> {
if self.scheme == "file" {
Some(&self.body)
} else {
None
}
}
/// Back to string form (`scheme://body`).
pub fn to_uri_string(&self) -> String {
todo!()
format!("{}://{}", self.scheme, self.body)
}
}
/// Whether `s` is a valid URI scheme (RFC 3986 §3.1).
fn is_scheme(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_paths_normalize_to_file() {
let uri = StorageUri::parse("/tmp/proj.ove").unwrap();
assert_eq!(uri.scheme, "file");
assert_eq!(uri.body, "/tmp/proj.ove");
assert_eq!(uri.to_uri_string(), "file:///tmp/proj.ove");
let rel = StorageUri::parse("proj.ove").unwrap();
assert_eq!(rel.to_uri_string(), "file://proj.ove");
}
#[test]
fn explicit_schemes_are_preserved() {
let uri = StorageUri::parse("file:///a/b.ove").unwrap();
assert_eq!(uri.scheme, "file");
assert_eq!(uri.body, "/a/b.ove");
let db = StorageUri::parse("oakdb://user:pass@host:5432/db").unwrap();
assert_eq!(db.scheme, "oakdb");
assert_eq!(db.body, "user:pass@host:5432/db");
// Scheme names follow RFC 3986: alphanumeric plus + - .
let with_dots = StorageUri::parse("oakdb+sqlite:///abs/path.db").unwrap();
assert_eq!(with_dots.scheme, "oakdb+sqlite");
assert_eq!(with_dots.body, "/abs/path.db");
}
#[test]
fn empty_input_is_invalid() {
assert!(matches!(
StorageUri::parse(""),
Err(crate::error::Error::Invalid)
));
}
#[test]
fn extension_is_lowercased_and_dotless() {
assert_eq!(
StorageUri::parse("/a/b.ove").unwrap().extension(),
Some("ove".to_string())
);
assert_eq!(
StorageUri::parse("/a/b.OTIO").unwrap().extension(),
Some("otio".to_string())
);
assert_eq!(
StorageUri::parse("/a/b.fcpxml").unwrap().extension(),
Some("fcpxml".to_string())
);
assert_eq!(StorageUri::parse("/a/b").unwrap().extension(), None);
assert_eq!(StorageUri::parse("/a/b.").unwrap().extension(), None);
// Dotfiles do not count as an extension.
assert_eq!(StorageUri::parse("/a/.ove").unwrap().extension(), None);
// Non-file schemes carry no extension.
assert_eq!(
StorageUri::parse("oakdb://conn").unwrap().extension(),
None
);
}
#[test]
fn local_path_is_file_body_only() {
assert_eq!(
StorageUri::parse("file:///x/y.ove").unwrap().local_path(),
Some("/x/y.ove")
);
assert_eq!(StorageUri::parse("/x/y.ove").unwrap().local_path(), Some("/x/y.ove"));
assert_eq!(StorageUri::parse("oakdb://c").unwrap().local_path(), None);
}
#[test]
fn uri_string_round_trips() {
for s in [
"file:///a/b.ove",
"oakdb://user@host/db",
"oakdb+sqlite:///abs/path.db",
] {
let uri = StorageUri::parse(s).unwrap();
assert_eq!(uri.to_uri_string(), s);
}
}
}
File diff suppressed because it is too large Load Diff
+140
View File
@@ -488,6 +488,146 @@ Used by `Footage`, `Sequence`, etc.
- `<outputpassthrough>`: pointer id of the node that provides the group's output.
- `<outputpassthrough>`:提供组输出的节点指针 ID。
### 5.5 Rust serializer extensions / Rust 序列化器扩展
The Rust serializer (`crates/oaknode/src/serializer.rs`) persists the
timeline structure through the per-node `<custom>` segments. The C++
format encodes it through connections (sequence `track_in_%1` array →
tracks, track `block_in` array → blocks); the Rust model keeps the
hierarchy in the behavior structs, so the segments below carry it. All
elements are **additive**: older readers (C++ `LoadCustom`,
`skipCurrentElement`) skip them, and the C++ reader remains byte-able
to open Rust files (only losing the fields below).
Rust 序列化器通过各节点的 `<custom>` 段持久化时间线结构。C++ 格式用连接编码
sequence 的 `track_in_%1` 数组 → 轨道,track 的 `block_in` 数组 → 块);
Rust 模型把层级放在行为结构体中,因此由以下段承载。所有元素都是**新增的**:
旧读取器(C++ `LoadCustom``skipCurrentElement`)会跳过它们。
`Sequence` (`org.olivevideoeditor.Olive.sequence`):
```xml
<custom>
<tracklists>
<tracklist>ptr</tracklist>
</tracklists>
</custom>
```
- `<tracklists>`: the video/audio/subtitle `TrackList` node references
(C++ writes workarea/markers here; those are opaque handles in Rust).
- `<tracklists>`:视频/音频/字幕 `TrackList` 节点引用(C++ 在此写
workarea/markersRust 中是透明句柄)。
`TrackList` (`org.olivevideoeditor.Olive.tracklist`):
```xml
<custom>
<type>0</type>
<arraybase>0</arraybase>
<sequence>ptr</sequence>
<tracks>
<track>ptr</track>
</tracks>
</custom>
```
- `<type>`: `Track::Type` integer (0 video, 1 audio, 2 subtitle).
- `<type>``Track::Type` 整数(0 视频、1 音频、2 字幕)。
- `<arraybase>`: the sequence `track_in_%1` input index this list owns.
- `<arraybase>`:该列表拥有的 sequence `track_in_%1` 输入下标。
- `<sequence>`: the owning sequence node reference.
- `<sequence>`:所属 sequence 节点引用。
- `<tracks>`: the `Track` node references in stack order.
- `<tracks>`:按栈序排列的 `Track` 节点引用。
`Track` (`org.olivevideoeditor.Olive.track`):
```xml
<custom>
<type>0</type>
<index>0</index>
<muted>0</muted>
<locked>0</locked>
<height>3</height>
<tracklist>ptr</tracklist>
<blocks>
<block>ptr</block>
</blocks>
</custom>
```
- `<height>`: the C++ element (internal units); the rest are Rust
additions. When `<type>` is absent (a C++ file), the kind is derived
from the sequence `track_in_%1` connection.
- `<height>` 是 C++ 元素(内部单位);其余为 Rust 新增。当 `<type>`
缺失(C++ 文件)时,从 sequence `track_in_%1` 连接推断类型。
Blocks (`clipblock` / `gapblock` / `transitionblock`):
```xml
<custom>
<range in="0/1" out="4/1"/>
<media_in>0/1</media_in>
<speed>1</speed>
<reversed>0</reversed>
<enabled>1</enabled>
<maintain_audio_pitch>0</maintain_audio_pitch>
<loop_mode>0</loop_mode>
<track>ptr</track>
<!-- clipblock only / 仅 clipblock -->
<footage>ptr</footage>
<!-- transitionblock only / 仅 transitionblock -->
<in_offset>0/1</in_offset>
<out_offset>0/1</out_offset>
</custom>
```
- `<range>`: the block's timeline span (C++ derives in/out from the
track order; the Rust block owns it).
- `<range>`:块的时间线区间(C++ 从轨道顺序推导 in/out;Rust 块直接持有)。
- `<track>` / `<footage>`: owning track / connected footage references.
- `<track>` / `<footage>`:所属轨道 / 关联素材引用。
`Footage` (`org.olivevideoeditor.Olive.footage`): the C++ elements
(`timestamp`, `proxy`, `sourcestarttime`, `viewer`) plus:
```xml
<custom>
<filename>/path/to/file.mp4</filename>
<streams>
<stream index="0" video="1" duration="num/den">
<video width="1920" height="1080" framerate="25/1"
pixelformat="4" channels="4"/>
</stream>
<stream index="1" video="0" duration="num/den">
<audio samplerate="48000" channellayout="3" format="4"/>
</stream>
</streams>
</custom>
```
- `<filename>`: the media path (C++ stores it in the `file_in` input;
Rust reads either on load). `<streams>`: the probed stream table.
- `<filename>`:媒体路径(C++ 存在 `file_in` 输入里;Rust 加载时两者都读)。
`<streams>`:探测到的流表。
`Folder` (`org.olivevideoeditor.Olive.folder`):
```xml
<custom>
<children>
<child>ptr</child>
</children>
</custom>
```
- `<children>`: the bin children (C++ attaches them through the
`child_in` input connections, which the Rust reader also folds in).
The folder node declares the `child_in` array input for C++ files.
- `<children>`:素材箱子项(C++ 通过 `child_in` 输入连接挂载;Rust 读取器
也把这些连接并入)。folder 节点声明 `child_in` 数组输入以兼容 C++ 文件。
---
## 6. `VideoParams` / 视频参数