// Oak Video Editor - Non-Linear Video Editor // Copyright (C) 2026 Oak Team // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . //! Project (de)serialization: the C++ `ProjectSerializer` family. //! //! XML I/O goes through oak_core's [`XmlReader`]/[`XmlWriter`] (direct //! Rust calls, single-lib unification). The XML shape mirrors //! the C++ `Node::save`/`Project::save` writers (`// CPP-PARITY: //! src/node/src/node.cpp:node::save`, `// CPP-PARITY: //! src/node/src/project.cpp:save`). Byte-exact output parity with the C++ //! writer is pinned by the golden tests in `tests/serializer_test.rs`; //! the value text codecs here use Rust's shortest round-trip formatting //! (functionally equivalent, not byte-identical — the golden test is //! `#[ignore]`d until the C++ fixtures are captured). //! //! The version ladder (210528/210907/211228/220403/230220) becomes a //! single reader with per-version adaptation: current files carry a //! `` root; unknown/newer roots are rejected. use std::sync::{Arc, Mutex}; use oak_core::xmlutils::{XmlReader, XmlWriter}; use oak_core::Rational; use crate::graph::Graph; use crate::id::NodeId; use crate::keyframe::{Interpolation, Keyframe}; use crate::node::NodeCore; use crate::project::{NodeRef, Project}; use crate::value::{NodeValue, ValueType}; /// Minimal XML reader surface the serializer needs (implemented over /// oak_core's `xmlutils`). pub trait XmlRead { /// Advance to the next start element; false at end/close. fn next_start_element(&mut self) -> bool; /// Current element name. fn name(&self) -> &str; /// Attribute by name. fn attribute(&self, name: &str) -> Option; /// Read inner text of the current element. fn read_element_text(&mut self) -> String; /// Skip the current element subtree. fn skip_current_element(&mut self); } /// Minimal XML writer surface. pub trait XmlWrite { /// Start an element. fn start_element(&mut self, name: &str); /// End the current element. fn end_element(&mut self); /// Write an attribute on the open element. fn attribute(&mut self, name: &str, value: &str); /// Write a text element. fn text_element(&mut self, name: &str, text: &str); /// Write raw character data (C++ `write_characters`); used for the /// ``/`` value payloads. Default no-op. fn characters(&mut self, _text: &str) {} } /// Reader over oak_core's [`XmlReader`]. pub struct XmlReaderBridge { /// The oak_core reader. reader: XmlReader, /// Current element name (cached). name: String, } /// Writer over oak_core's [`XmlWriter`]. pub struct XmlWriterBridge { /// The oak_core writer. writer: XmlWriter, } impl XmlReaderBridge { /// Create from XML text; `None` on a parse error. pub fn new(xml: &str) -> Option { let reader = XmlReader::new(xml).ok()?; Some(XmlReaderBridge { reader, name: String::new(), }) } } impl XmlRead for XmlReaderBridge { fn next_start_element(&mut self) -> bool { if self.reader.read_next_start_element().unwrap_or(false) { self.name = self.reader.name().unwrap_or_default(); true } else { false } } fn name(&self) -> &str { &self.name } fn attribute(&self, name: &str) -> Option { let count = self.reader.attribute_count().unwrap_or(0); for i in 0..count { let attr_name = self.reader.attribute_name(i).unwrap_or_default(); if attr_name == name { return self.reader.attribute_value(i).ok(); } } None } fn read_element_text(&mut self) -> String { self.reader.read_element_text().unwrap_or_default() } fn skip_current_element(&mut self) { let _ = self.reader.skip_current_element(); } } impl XmlWriterBridge { /// Create a writer. pub fn new() -> Option { Some(XmlWriterBridge { writer: XmlWriter::new(), }) } /// The serialized output. pub fn output(&self) -> String { self.writer.output().unwrap_or_default() } } impl XmlWrite for XmlWriterBridge { fn start_element(&mut self, name: &str) { let _ = self.writer.write_start_element(name); } fn end_element(&mut self) { let _ = self.writer.write_end_element(); } fn attribute(&mut self, name: &str, value: &str) { let _ = self.writer.write_attribute(name, value); } fn text_element(&mut self, name: &str, text: &str) { let _ = self.writer.write_text_element(name, text); } fn characters(&mut self, text: &str) { let _ = self.writer.write_characters(text); } } /// Detected project version (from the XML header). #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct ProjectVersion(pub u32); /// The current (build) project version. pub const CURRENT_VERSION: ProjectVersion = ProjectVersion(230220); /// Value text codec: [`NodeValue`] -> string for the XML `` /// payloads. `key_track` selects the per-component form (C++ /// `NodeValue::value_to_string`; `// CPP-PARITY: value.cpp:45`). pub fn value_to_string(declared: ValueType, value: &NodeValue, key_track: bool) -> String { match declared { ValueType::Vec2 | ValueType::Vec3 | ValueType::Vec4 | ValueType::Color => { let comps: Vec = value .split_into_tracks(declared) .iter() .map(|t| format!("{}", t.to_double())) .collect(); if key_track { // Per-track keyframes carry one component. comps.first().cloned().unwrap_or_default() } else { comps.join(":") } } ValueType::Rational => match value { NodeValue::Rational(r) => r.to_display_string(), _ => format!("{}", value.to_double()), }, ValueType::Int | ValueType::Combo => format!("{}", value.to_double() as i64), ValueType::Boolean => { if value.to_double() != 0.0 { "1".to_string() } else { "0".to_string() } } ValueType::Float => format!("{}", value.to_double()), ValueType::Text | ValueType::StrCombo | ValueType::Parametric => match value { NodeValue::Text(s) => s.clone(), NodeValue::StrCombo(s) => s.clone(), _ => String::new(), }, _ => String::new(), } } /// String -> [`NodeValue`] (C++ `NodeValue::string_to_value`). pub fn string_to_value(declared: ValueType, text: &str) -> NodeValue { match declared { ValueType::Vec2 | ValueType::Vec3 | ValueType::Vec4 | ValueType::Color => { let parts: Vec = text.split(':').map(|p| p.parse().unwrap_or(0.0)).collect(); match declared { ValueType::Vec2 => { NodeValue::Vec2([parts[0], parts.get(1).copied().unwrap_or(0.0)]) } ValueType::Vec3 => NodeValue::Vec3([ parts[0], parts.get(1).copied().unwrap_or(0.0), parts.get(2).copied().unwrap_or(0.0), ]), ValueType::Vec4 => NodeValue::Vec4([ parts[0], parts.get(1).copied().unwrap_or(0.0), parts.get(2).copied().unwrap_or(0.0), parts.get(3).copied().unwrap_or(0.0), ]), _ => NodeValue::Color([ parts[0], parts.get(1).copied().unwrap_or(0.0), parts.get(2).copied().unwrap_or(0.0), parts.get(3).copied().unwrap_or(0.0), ]), } } ValueType::Rational => NodeValue::Rational(Rational::from_string(text)), ValueType::Int | ValueType::Combo => NodeValue::Int(text.trim().parse().unwrap_or(0)), ValueType::Boolean => NodeValue::Boolean(text.trim() == "1" || text.trim() == "true"), ValueType::Float => NodeValue::Float(text.trim().parse().unwrap_or(0.0)), ValueType::Text => NodeValue::Text(text.to_string()), ValueType::StrCombo => NodeValue::StrCombo(text.to_string()), ValueType::Parametric => NodeValue::Text(text.to_string()), _ => NodeValue::None, } } /// Interpolation from the XML `type` attribute (C++ /// `NodeKeyframe::Type`). pub fn interpolation_from_c(t: i32) -> Interpolation { match t { 1 => Interpolation::Hold, 2 => Interpolation::Bezier, _ => Interpolation::Linear, } } /// Parse a serialized node identity (a `` `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 { text.trim().parse::().ok().and_then(NodeId::from_identity) } /// Save a whole project to the current-version XML format. pub fn save(project: &Project) -> crate::error::Result { use crate::error::Error; let mut writer = XmlWriterBridge::new().ok_or(Error::Failed( "oak_core XML writer unavailable".to_string(), ))?; writer.start_element("project"); writer.attribute("version", "1"); writer.text_element("uuid", &project.uuid); writer.start_element("nodes"); for id in project.graph.node_ids() { let entry = project.graph.get(id).ok_or(Error::NotFound)?; writer.start_element("node"); let type_id = entry.behavior.type_id().to_string(); // The node's input connections: (source, input_id, element). let connections: Vec<(NodeId, String, i32)> = project .graph .output_connections_all() .into_iter() .filter(|(_, to, _, _)| *to == id) .map(|(from, _, input, element)| (from, input, element)) .collect(); save_node(&mut writer, &entry.core, &*entry.behavior, id, &type_id, &connections)?; writer.end_element(); // node } writer.end_element(); // nodes writer.start_element("settings"); let mut keys: Vec<&String> = project.settings.keys().collect(); keys.sort(); for key in keys { // The cache location lives on the struct fields; any settings-map // copies (from a load) are skipped — the fields win on save. if key == crate::project::SETTING_CACHE_LOCATION || key == crate::project::SETTING_CACHE_PATH { continue; } writer.text_element(key, project.settings.get(key).unwrap_or(&String::new())); } // Persist the cache location as settings entries (the C++ // k_cache_location_setting_key / k_cache_path_key); defaults add no // noise to the file. if project.cache_location_setting != 0 { writer.text_element( crate::project::SETTING_CACHE_LOCATION, &project.cache_location_setting.to_string(), ); } if !project.custom_cache_path.is_empty() { writer.text_element(crate::project::SETTING_CACHE_PATH, &project.custom_cache_path); } writer.end_element(); // settings writer.end_element(); // project Ok(writer.output()) } /// Save one node (C++ `Node::save`). `connections` lists the node's /// 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)], ) -> crate::error::Result<()> { writer.attribute("version", "1"); writer.attribute("id", type_id); writer.attribute("ptr", &id.identity().to_string()); // 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::()) { 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()); } for input in &core.inputs { writer.start_element("input"); writer.attribute("id", &input.id); save_input(writer, core, &input.id); writer.end_element(); // input } if !core.links.is_empty() { writer.start_element("links"); for link in &core.links { writer.text_element("link", &link.identity().to_string()); } writer.end_element(); // links } if !connections.is_empty() { writer.start_element("connections"); for (from, input_id, element) in connections { writer.start_element("connection"); writer.attribute("input", input_id); writer.attribute("element", &element.to_string()); writer.text_element("output", &from.identity().to_string()); writer.end_element(); // connection } writer.end_element(); // connections } writer.start_element("caches"); writer.text_element("audio", ""); writer.text_element("video", ""); writer.text_element("thumb", ""); writer.text_element("waveform", ""); writer.end_element(); // caches writer.start_element("custom"); behavior.save_custom(core, writer); writer.end_element(); // custom Ok(()) } /// Save one input element (`primary` + `subelements`; C++ /// `Node::save_input`). fn save_input(writer: &mut dyn XmlWrite, core: &NodeCore, id: &str) { writer.start_element("primary"); save_immediate(writer, core, id, -1); writer.end_element(); // primary let arr_sz = core.input_array_size(id); if arr_sz > 0 { writer.start_element("subelements"); writer.attribute("count", &arr_sz.to_string()); for i in 0..arr_sz { writer.start_element("element"); save_immediate(writer, core, id, i as i32); writer.end_element(); // element } writer.end_element(); // subelements } } /// Save one immediate (standard values + keyframes; C++ /// `Node::save_immediate`). fn save_immediate(writer: &mut dyn XmlWrite, core: &NodeCore, id: &str, element: i32) { let keyframable = core.input_flags(id) & crate::input::flags::NOT_KEYFRAMABLE == 0; let keyframing = core .keyframe_track(id, element) .map(|t| !t.keys().is_empty()) .unwrap_or(false); let declared = core.input_data_type(id).unwrap_or(ValueType::None); if keyframable { writer.text_element("keyframing", if keyframing { "1" } else { "0" }); } // Standard value, split into per-component tracks. writer.start_element("standard"); let value = core.standard_value(id, element); for track in value.split_into_tracks(declared) { writer.start_element("track"); writer_text_chars(writer, &value_to_string(declared, &track, true)); writer.end_element(); // track } writer.end_element(); // standard if keyframing { writer.start_element("keyframes"); if let Some(track) = core.keyframe_track(id, element) { writer.start_element("track"); for key in track.keys() { writer.start_element("key"); writer.attribute("input", id); writer.attribute("time", &key.time.to_display_string()); let type_c = match key.interpolation { Interpolation::Hold => 1, Interpolation::Bezier => 2, Interpolation::Linear => 0, }; writer.attribute("type", &type_c.to_string()); writer.attribute("inhandlex", &format!("{}", key.bezier_in.0)); writer.attribute("inhandley", &format!("{}", key.bezier_in.1)); writer.attribute("outhandlex", &format!("{}", key.bezier_out.0)); writer.attribute("outhandley", &format!("{}", key.bezier_out.1)); writer_text_chars(writer, &value_to_string(declared, &key.value, true)); writer.end_element(); // key } writer.end_element(); // track } writer.end_element(); // keyframes } } /// Write character data through the writer trait. fn writer_text_chars(writer: &mut dyn XmlWrite, text: &str) { writer.characters(text); } /// Load a project from XML text. Applies version upgrades in order; /// rejects versions newer than the build (C++ `k_project_too_new`). pub fn load(xml: &str) -> crate::error::Result>> { Ok(load_with_id_map(xml)?.0) } /// Deserialize a project and also return the source-identity -> loaded-id /// translation map built while loading (XML `ptr` -> [`NodeId`]). /// /// `load` rebuilds the graph in file order and assigns fresh arena slots, /// so a node's identity in the saved project does not generally match its /// identity after loading (a gap left by any deleted slot shifts every /// later node). Callers that hold identities from the *saved* project — /// e.g. a render worker resolving a ticket's viewer node against the /// snapshot it loaded — must translate through this map. pub fn load_with_id_map( xml: &str, ) -> crate::error::Result<(Arc>, std::collections::HashMap)> { use crate::error::Error; let mut reader = XmlReaderBridge::new(xml).ok_or(Error::Failed( "oak_core XML reader unavailable".to_string(), ))?; // Detect the root element. if !reader.next_start_element() { return Err(Error::Failed("empty XML document".to_string())); } let root = reader.name().to_string(); let root_version = reader .attribute("version") .and_then(|v| v.parse::().ok()); // Version gate: reject unknown/newer roots. match root.as_str() { "project" => { // Current format (version 1 of the project schema). let _ = root_version; } "olive" => { // Historical roots: accept and upgrade when the version is // known (<= current), reject newer. if let Some(v) = root_version { if v > CURRENT_VERSION.0 { return Err(Error::Failed(format!( "project version {} is newer than this build ({})", v, CURRENT_VERSION.0 ))); } } } _ => { return Err(Error::Failed(format!( "unrecognized project root element '{}'", root ))); } } let project = Project::new(); let id_map = { let mut guard = lock(&project); load_project_body(&mut reader, &mut guard)? }; Ok((project, id_map)) } /// Parse the `` body: uuid, nodes, settings. C++ full saves /// wrap the data in a container (` /// ......` — `// CPP-PARITY: /// serializer230220.cpp`); a nested `` 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. let mut id_map: std::collections::HashMap = std::collections::HashMap::new(); // Deferred connections: (output_identity, input_node_id, input_id, element). let mut connections: Vec<(u64, NodeId, String, i32)> = Vec::new(); // Deferred links: (identity_a, identity_b). let mut links: Vec<(u64, u64)> = Vec::new(); 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(); } "nodes" => { while reader.next_start_element() { if reader.name() == "node" { let id = load_node( reader, &mut project.graph, &mut id_map, &mut connections, &mut links, )?; if project.root == NodeId::INVALID { // The first node is the root folder when the // project has no explicit root setting. project.root = id; } } else { reader.skip_current_element(); } } } "settings" => { while reader.next_start_element() { let key = reader.name().to_string(); let val = reader.read_element_text(); project.settings.insert(key, val); } } _ => reader.skip_current_element(), } } // Resolve connections. for (out_identity, in_id, input_id, element) in connections { if let Some(out_id) = id_map.get(&out_identity) { project .graph .connect(*out_id, in_id, &input_id, element) .ok(); } } // Resolve links (the writer emits one entry per direction; linking // is symmetric so each pair resolves to the same edge). for (a, b) in links { if let (Some(ai), Some(bi)) = (id_map.get(&a), id_map.get(&b)) { project.graph.link(*ai, *bi); } } // Root setting: honor an explicit "root" setting if present. if let Some(root) = project.settings.get("root") { if let Ok(identity) = root.parse::() { if let Some(id) = id_map.get(&identity) { project.root = *id; } } } // The cache location arrives as settings entries (see the save side). if let Some(setting) = project .settings .get(crate::project::SETTING_CACHE_LOCATION) .and_then(|s| s.parse::().ok()) { // Clamp to the known CacheSetting range: a hand-edited file must not // poison the dialog's combo selection. project.cache_location_setting = setting.clamp(0, 2); } if let Some(path) = project.settings.get(crate::project::SETTING_CACHE_PATH) { project.custom_cache_path = path.clone(); } // 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(id_map) } /// Parse one `` into the graph; returns its id. #[allow(clippy::too_many_arguments)] fn load_node( reader: &mut dyn XmlRead, graph: &mut Graph, id_map: &mut std::collections::HashMap, connections: &mut Vec<(u64, NodeId, String, i32)>, links: &mut Vec<(u64, u64)>, ) -> crate::error::Result { use crate::error::Error; let type_id = reader.attribute("id").unwrap_or_default(); // The packed identity the file assigns this node (`ptr`). `None` when // the attribute is absent (foreign/old files); only present `ptr`s are // registered so an explicit identity of `0` (the first-created node) // still resolves its outgoing connections. let ptr = reader.attribute("ptr").and_then(|p| p.parse::().ok()); // Instantiate the node type; timeline structural types (which are // not in the factory menu) are reconstructed directly, unknown // types fall back to an error. `create_any` also covers the dynamic // (runtime-registered OpenFX plugin) entries — `find` alone would // reject every project that carries a plugin node. let (mut core, behavior): (NodeCore, Box) = match create_timeline_type(&type_id) { Some(x) => x, None => match crate::factory::Factory::global().create_any(&type_id) { Some(x) => x, 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 // connections/links can reference it by id. let id = graph.add_node(core, behavior); if let Some(ptr) = ptr { id_map.insert(ptr, id); } // 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, &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)> { 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 `` 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)>, ) -> crate::error::Result<()> { while reader.next_start_element() { match reader.name() { "label" => core.label = reader.read_element_text(), "color" => { core.override_color = reader.read_element_text().trim().parse().unwrap_or(-1) } "input" => { let input_id = reader.attribute("id").unwrap_or_default(); load_input_element(reader, core, &input_id); } "links" => { while reader.next_start_element() { if reader.name() == "link" { if let Ok(identity) = reader.read_element_text().trim().parse::() { links.push((node_id.identity(), identity)); } } else { reader.skip_current_element(); } } } "connections" => { while reader.next_start_element() { if reader.name() == "connection" { let input_id = reader.attribute("input").unwrap_or_default(); let element = reader .attribute("element") .and_then(|e| e.parse::().ok()) .unwrap_or(-1); let mut output = 0u64; while reader.next_start_element() { if reader.name() == "output" { output = reader.read_element_text().trim().parse().unwrap_or(0); } else { reader.skip_current_element(); } } connections.push((output, node_id, input_id, element)); } else { reader.skip_current_element(); } } } "caches" => reader.skip_current_element(), "custom" => { // The reader is positioned at ``; the behavior // parses its own segment (the default no-op skips it). behavior.load_custom(core, reader); } _ => reader.skip_current_element(), } } Ok(()) } /// Parse one `` element: the primary immediate and subelements. fn load_input_element(reader: &mut dyn XmlRead, core: &mut NodeCore, input_id: &str) { let declared = core.input_data_type(input_id).unwrap_or(ValueType::None); // Locate the input in the core (it exists because the node // constructor created it). if reader.next_start_element() && reader.name() == "primary" { load_immediate(reader, core, input_id, -1, declared); } // The primary immediate consumes up to its end; the loop below // re-enters at the next start element (subelements). while reader.next_start_element() { match reader.name() { "subelements" => { // Count attr; elements follow as ``. while reader.next_start_element() { if reader.name() == "element" { // Element index derived from order. let element = core.input_array_size(input_id) as i32; core.input_array_insert(input_id, element.max(0) as usize); load_immediate(reader, core, input_id, element, declared); } else { reader.skip_current_element(); } } } _ => reader.skip_current_element(), } } } /// Parse one immediate (standard values + keyframes). fn load_immediate( reader: &mut dyn XmlRead, core: &mut NodeCore, input_id: &str, element: i32, declared: ValueType, ) { let mut keyframing = false; let mut standard_tracks: Vec = Vec::new(); let mut keyframe_tracks: Vec> = Vec::new(); while reader.next_start_element() { match reader.name() { "keyframing" => { keyframing = reader.read_element_text().trim() == "1"; } "standard" => { standard_tracks.clear(); while reader.next_start_element() { if reader.name() == "track" { let text = reader.read_element_text(); standard_tracks.push(string_to_value(declared, &text)); } else { reader.skip_current_element(); } } } "keyframes" => { keyframe_tracks.clear(); while reader.next_start_element() { if reader.name() == "track" { let mut track = Vec::new(); while reader.next_start_element() { if reader.name() == "key" { let time = reader .attribute("time") .map(|t| Rational::from_string(&t)) .unwrap_or_else(|| Rational::new(0, 1)); let type_c = reader .attribute("type") .and_then(|t| t.parse::().ok()) .unwrap_or(0); let in_x = reader .attribute("inhandlex") .and_then(|v| v.parse::().ok()) .unwrap_or(0.0); let in_y = reader .attribute("inhandley") .and_then(|v| v.parse::().ok()) .unwrap_or(0.0); let out_x = reader .attribute("outhandlex") .and_then(|v| v.parse::().ok()) .unwrap_or(0.0); let out_y = reader .attribute("outhandley") .and_then(|v| v.parse::().ok()) .unwrap_or(0.0); let text = reader.read_element_text(); let value = string_to_value(declared, &text); track.push(Keyframe { time, value, interpolation: interpolation_from_c(type_c), bezier_in: (in_x, in_y), bezier_out: (out_x, out_y), }); } else { reader.skip_current_element(); } } keyframe_tracks.push(track); } else { reader.skip_current_element(); } } } _ => reader.skip_current_element(), } } // Apply the parsed state. let value = NodeValue::combine_tracks(&standard_tracks, declared); core.set_standard_value(input_id, element, value); if keyframing { let track = core.keyframe_track_mut(input_id, element); for key in keyframe_tracks.into_iter().flatten() { track.set_key(key); } } else { // No keyframes: drop any pre-existing track. core.keyframes .retain(|(i, e, _)| !(i == input_id && *e == element)); } } /// 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) { 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::()) { 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::()) { 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::()) { 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::()) { 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::()) { 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::()) { 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::()) { // 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::()) .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::() { 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::()) { t.kind = kind; } } } // MultiCamNode: rebuild the cached sequence reference (C++ // `sequence_`) from the restored `sequence_in` edge — the graph arena // fires no connect events at load, so the behavior state is synced // here (also re-applies the `sequence_type_in` unhide of the C++ // `InputConnectedEvent`). for id in graph.node_ids() { let seq = graph.connected_output( id, crate::nodes::multicamnode::SEQUENCE_INPUT, -1, ); if seq.is_none() { continue; } if let Some(entry) = graph.get_mut(id) { if let Some(mc) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { mc.set_sequence(&mut entry.core, seq); } } } } /// 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, ) { 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::()) { 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(m: &Mutex) -> std::sync::MutexGuard<'_, T> { m.lock().unwrap_or_else(|e| e.into_inner()) } #[cfg(test)] mod tests { use super::*; /// Value codec 对 Parametric 输入的往返(save_immediate / /// load_immediate 对 standard 值走的纯函数路径:split → /// value_to_string → string_to_value → combine;XML 桥由 /// tests/serializer_test.rs 覆盖)。 #[test] fn parametric_input_value_codec_roundtrip() { let declared = ValueType::Parametric; let json = r#"{"curves":[[{"key":0,"value":0,"slope":1},{"key":0.5,"value":0.6,"slope":1}]]}"#; let value = NodeValue::Text(json.to_string()); // save 侧:standard 值按声明类型拆轨(Text → 单轨整值)。 let tracks = value.split_into_tracks(declared); assert_eq!(tracks.len(), 1); let text = value_to_string(declared, &tracks[0], true); assert_eq!(text, json); // load 侧:track 文本还原 + 合并回整值。 let restored = string_to_value(declared, &text); assert_eq!(restored, value); let combined = NodeValue::combine_tracks(&[restored], declared); assert_eq!(combined, value); // key_track=false 形态(整值轨道)同样往返。 assert_eq!(value_to_string(declared, &value, false), json); // 载荷不是 Text(防御)→ 空串;声明类型 Parametric 无键帧 // 插值、无 C++ 判别值。 assert_eq!(value_to_string(declared, &NodeValue::None, true), ""); assert!(!declared.can_interpolate()); assert_eq!(declared.to_cpp_discriminant(), 0); assert!(declared.is_string()); assert_eq!(declared.to_oak(), crate::value::oak::STRING); } }