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:
Generated
+1232
-7
File diff suppressed because it is too large
Load Diff
+6
-8
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!()
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(®istry.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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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/markers;Rust 中是透明句柄)。
|
||||
|
||||
`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` / 视频参数
|
||||
|
||||
Reference in New Issue
Block a user