From 2248be8567d36c8b6a027861b77da47678c11fd9 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 14 Aug 2026 15:14:38 +0800 Subject: [PATCH] feat(storage): real oakstorage file backends + full-timeline .ove serializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 behavior hooks with two-phase reference resolution; loads the C++ containers (golden: tests/ project_with_footage.ove); round-trip is field-by-field and byte-idempotent. --- Cargo.lock | 1239 +++++++++++++++++++- Cargo.toml | 14 +- crates/oaknode/src/block.rs | 147 +++ crates/oaknode/src/ffi.rs | 9 +- crates/oaknode/src/folder.rs | 60 +- crates/oaknode/src/footage.rs | 199 +++- crates/oaknode/src/node.rs | 6 +- crates/oaknode/src/sequence.rs | 95 +- crates/oaknode/src/serializer.rs | 283 ++++- crates/oaknode/src/track.rs | 159 +++ crates/oaknode/tests/serializer_test.rs | 728 ++++++++++++ crates/oakstorage/Cargo.toml | 14 +- crates/oakstorage/README.md | 71 +- crates/oakstorage/src/backend.rs | 52 +- crates/oakstorage/src/backends/database.rs | 18 +- crates/oakstorage/src/backends/mod.rs | 9 +- crates/oakstorage/src/backends/otio.rs | 600 +++++++++- crates/oakstorage/src/backends/ove_xml.rs | 129 +- crates/oakstorage/src/bridge/node.rs | 163 ++- crates/oakstorage/src/ffi.rs | 317 ++++- crates/oakstorage/src/handle.rs | 149 ++- crates/oakstorage/src/registry.rs | 177 ++- crates/oakstorage/src/session.rs | 27 +- crates/oakstorage/src/uri.rs | 157 ++- crates/oakstorage/tests/storage_test.rs | 1024 +++++++++++++++- docs/project-file-reference.md | 140 +++ 26 files changed, 5755 insertions(+), 231 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 672f0eb4e..07b787cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -126,6 +137,7 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -140,6 +152,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "aligned" version = "0.4.3" @@ -267,6 +285,165 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "arrow" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex 0.4.6", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64", + "chrono", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" + +[[package]] +name = "arrow-select" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + [[package]] name = "as-raw-xcb-connection" version = "1.0.1" @@ -457,6 +634,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-task" version = "4.7.1" @@ -487,6 +686,15 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic" version = "0.5.3" @@ -606,6 +814,20 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -704,6 +926,18 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block" version = "0.1.6" @@ -774,10 +1008,24 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" dependencies = [ + "borsh-derive", "bytes", "cfg_aliases", ] +[[package]] +name = "borsh-derive" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "bstr" version = "1.13.0" @@ -800,6 +1048,28 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytemuck" version = "1.25.2" @@ -932,6 +1202,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1017,6 +1298,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cocoa" version = "0.25.0" @@ -1378,6 +1665,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1456,6 +1758,49 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1468,6 +1813,26 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1511,6 +1876,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1602,6 +1968,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1637,6 +2009,9 @@ name = "either" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] [[package]] name = "embed-resource" @@ -1745,6 +2120,16 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "euclid" version = "0.22.14" @@ -1909,6 +2294,17 @@ dependencies = [ "spin 0.9.9", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.9", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2015,6 +2411,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futf" version = "0.1.5" @@ -2080,6 +2482,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.33" @@ -2210,6 +2623,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -2625,7 +3039,7 @@ dependencies = [ "async-task", "backtrace", "chrono", - "flume", + "flume 0.11.1", "futures", "parking_lot", "rand 0.9.5", @@ -2815,6 +3229,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2847,6 +3270,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heapless" version = "0.9.3" @@ -2893,7 +3325,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -2905,6 +3346,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "home" version = "0.5.12" @@ -3039,6 +3489,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3322,6 +3778,63 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libc" version = "0.2.189" @@ -3363,6 +3876,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -3491,6 +4015,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "serde", + "winapi", +] + [[package]] name = "mach2" version = "0.5.0" @@ -3529,6 +4064,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" @@ -3615,6 +4160,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -3719,6 +4275,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -3850,6 +4407,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -4064,6 +4627,21 @@ dependencies = [ "wgpu 25.0.2", ] +[[package]] +name = "oakstorage" +version = "0.1.0" +dependencies = [ + "oakcodec", + "oakcore-rs", + "oaknode", + "oakotio", + "sea-orm", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", +] + [[package]] name = "oaktask" version = "0.1.0" @@ -4371,9 +4949,9 @@ dependencies = [ "futures-lite 2.6.1", "futures-util", "getrandom 0.4.3", - "hkdf", - "hmac", - "md-5", + "hkdf 0.12.4", + "hmac 0.12.1", + "md-5 0.10.6", "num 0.4.3", "num-bigint-dig", "pbkdf2", @@ -4431,6 +5009,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + [[package]] name = "parking" version = "2.2.1" @@ -4498,7 +5100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", ] [[package]] @@ -4507,6 +5109,15 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pgvector" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3673cba5b9a124916096a423b806a9f29620972c6c97b08db5f2053e9428b481" +dependencies = [ + "serde", +] + [[package]] name = "phf" version = "0.13.1" @@ -4599,6 +5210,16 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "pluralizer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3eba432a00a1f6c16f39147847a870e94e2e9b992759b503e330efec778cbe" +dependencies = [ + "once_cell", + "regex", +] + [[package]] name = "png" version = "0.17.16" @@ -4713,6 +5334,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4756,6 +5383,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + [[package]] name = "profiling" version = "1.0.18" @@ -4817,6 +5457,26 @@ dependencies = [ "cc", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "pulp" version = "0.22.3" @@ -4906,6 +5566,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.7" @@ -4927,6 +5593,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4965,6 +5642,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -5191,6 +5874,15 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -5223,6 +5915,35 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -5265,6 +5986,23 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.7", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -5430,6 +6168,146 @@ dependencies = [ "once_cell", ] +[[package]] +name = "sea-bae" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260bbc7148a8d6818ac5032a1b9970da1a68bdd723d45b12d59f7c1bf3565e24" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sea-orm" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334e83ced3ae3ee44db0f84d1fcf8d2087a1ad9bb9036f00f9f6067156ea197" +dependencies = [ + "async-stream", + "async-trait", + "bigdecimal", + "chrono", + "derive-where", + "derive_more", + "futures-util", + "itertools 0.14.0", + "log", + "mac_address", + "ouroboros", + "pgvector", + "rust_decimal", + "sea-orm-arrow", + "sea-orm-macros", + "sea-query", + "sea-query-sqlx", + "sea-schema", + "serde", + "serde_json", + "sqlx", + "sqlx-core", + "strum 0.28.0", + "thiserror 2.0.20", + "time", + "tracing", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "sea-orm-arrow" +version = "2.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c800d9db902534d7d01728faf98e33d13c1d57bb8c57d8e4c518309172bddda" +dependencies = [ + "arrow", + "sea-query", + "thiserror 2.0.20", +] + +[[package]] +name = "sea-orm-macros" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4039a86f9acc4d3b52747508b347dddc6fd725bbc429902ebeb6d26225fc2528" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "pluralizer", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.119", + "unicode-ident", +] + +[[package]] +name = "sea-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31" +dependencies = [ + "chrono", + "ordered-float 4.6.0", + "rust_decimal", + "sea-query-derive", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "sea-query-derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537" +dependencies = [ + "darling", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.20", +] + +[[package]] +name = "sea-query-sqlx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eaa419cdb9157da1361186b1959983eb2ea0dcb9a3c69dc45c449ecb2af8fef" +dependencies = [ + "sea-query", + "sqlx", +] + +[[package]] +name = "sea-schema" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3553c77dceed56e95bece9ea876c4dd67ca879ef51055a0b97a7bb89a8ae4fed" +dependencies = [ + "async-trait", + "sea-query", + "sea-query-sqlx", + "sea-schema-derive", + "sqlx", +] + +[[package]] +name = "sea-schema-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "seahash" version = "4.1.0" @@ -5568,6 +6446,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -5633,6 +6522,12 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplecss" version = "0.2.2" @@ -5688,6 +6583,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "smol" @@ -5716,6 +6614,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.9.9" @@ -5752,6 +6660,191 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rust_decimal", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "rust_decimal", + "serde", + "sha1", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.20", + "time", + "tracing", + "uuid", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags 2.13.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "itoa", + "log", + "md-5 0.11.0", + "memchr", + "rand 0.10.2", + "rust_decimal", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume 0.12.0", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.20", + "time", + "tracing", + "url", + "uuid", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5807,6 +6900,17 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5831,6 +6935,12 @@ dependencies = [ "strum_macros 0.27.2", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + [[package]] name = "strum_macros" version = "0.26.4" @@ -5968,6 +7078,17 @@ dependencies = [ "zeno", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -6054,6 +7175,12 @@ dependencies = [ "objc", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.27.0" @@ -6141,6 +7268,36 @@ dependencies = [ "zune-jpeg 0.5.15", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -6201,6 +7358,43 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -6399,6 +7593,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -6633,6 +7836,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -7130,6 +8334,12 @@ dependencies = [ "winsafe", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + [[package]] name = "winapi" version = "0.3.9" @@ -7704,6 +8914,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" @@ -7816,6 +9035,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yazi" version = "0.2.1" @@ -8006,7 +9231,7 @@ version = "0.4.0-zed" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642" dependencies = [ - "ahash", + "ahash 0.8.12", "hashbrown 0.14.5", "log", "x11rb", diff --git a/Cargo.toml b/Cargo.toml index 111ebea64..54c1d60ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/crates/oaknode/src/block.rs b/crates/oaknode/src/block.rs index 06b60422a..4e27fbce8 100644 --- a/crates/oaknode/src/block.rs +++ b/crates/oaknode/src/block.rs @@ -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 `` 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 diff --git a/crates/oaknode/src/ffi.rs b/crates/oaknode/src/ffi.rs index 4f08317f2..0645f5976 100644 --- a/crates/oaknode/src/ffi.rs +++ b/crates/oaknode/src/ffi.rs @@ -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 diff --git a/crates/oaknode/src/folder.rs b/crates/oaknode/src/folder.rs index 0694ad31e..88f864f3a 100644 --- a/crates/oaknode/src/folder.rs +++ b/crates/oaknode/src/folder.rs @@ -111,12 +111,66 @@ impl NodeBehavior for FolderBehavior { fn duplicate(&self, _core: &NodeCore) -> Option> { 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) { - // 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; diff --git a/crates/oaknode/src/footage.rs b/crates/oaknode/src/footage.rs index cabdee00f..fdca7808a 100644 --- a/crates/oaknode/src/footage.rs +++ b/crates/oaknode/src/footage.rs @@ -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) { + 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. `` + /// and `` 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) } diff --git a/crates/oaknode/src/node.rs b/crates/oaknode/src/node.rs index bdef33798..6c6b4aea7 100644 --- a/crates/oaknode/src/node.rs +++ b/crates/oaknode/src/node.rs @@ -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 `` start element). + reader.skip_current_element(); true } diff --git a/crates/oaknode/src/sequence.rs b/crates/oaknode/src/sequence.rs index d9eebb605..0595a6840 100644 --- a/crates/oaknode/src/sequence.rs +++ b/crates/oaknode/src/sequence.rs @@ -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) { + 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) } diff --git a/crates/oaknode/src/serializer.rs b/crates/oaknode/src/serializer.rs index d03d8780c..e30221521 100644 --- a/crates/oaknode/src/serializer.rs +++ b/crates/oaknode/src/serializer.rs @@ -278,6 +278,15 @@ pub fn interpolation_from_c(t: i32) -> Interpolation { } } +/// 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; @@ -303,7 +312,7 @@ pub fn save(project: &Project) -> crate::error::Result { .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 { } /// 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::()) + { + 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>> { Ok(project) } -/// Parse the `` body: uuid, nodes, settings. +/// 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. @@ -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::().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) = - 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)> { + 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)>, @@ -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 ``; 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) { + 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; + } + } + } +} + +/// 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()) diff --git a/crates/oaknode/src/track.rs b/crates/oaknode/src/track.rs index 14932d3cc..1611a1e81 100644 --- a/crates/oaknode/src/track.rs +++ b/crates/oaknode/src/track.rs @@ -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) { + (NodeCore::new(), Box::new(TrackBehavior::new(TrackType::Video))) + } + /// Block at `index` (None out of range). pub fn block_at(&self, index: usize) -> Option { 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) { + (NodeCore::new(), Box::new(TrackListBehavior::new(TrackType::Video))) + } + /// Track at `index`. pub fn track_at(&self, index: usize) -> Option { 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 ``, 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::() + .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::() + .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) } diff --git a/crates/oaknode/tests/serializer_test.rs b/crates/oaknode/tests/serializer_test.rs index efdc49ac2..c4798c9b9 100644 --- a/crates/oaknode/tests/serializer_test.rs +++ b/crates/oaknode/tests/serializer_test.rs @@ -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 ``/`` 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::()) + .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::()) + .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::()) + .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> { + 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::()) + .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::()) + .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::()) + .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::()) + .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::()) + .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::()) + .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::()) + .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::()) + .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::()) + .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::()) + .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::()) + .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::()) + .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::()) + .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::()) + .unwrap(); + let lf = loaded + .graph + .get(loaded.root) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .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::()) + .unwrap(); + let l_f = loaded + .graph + .get(l_footage) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .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::()) + .unwrap(); + let l_s = loaded + .graph + .get(l_seq) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .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::()) + .unwrap(); + let l_t = loaded + .graph + .get(l_vtrack) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .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::()) + .unwrap(); + let l_c1 = loaded + .graph + .get(l_clip1) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .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::()) + .unwrap(); + let l_g1 = loaded + .graph + .get(l_gap1) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .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::()) + .unwrap(); + let l_c2 = loaded + .graph + .get(l_clip2) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .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::()) + .unwrap(); + let l_at = loaded + .graph + .get(l_atrack) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .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::()) + .unwrap(); + let l_c3 = loaded + .graph + .get(l_at.blocks[0]) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .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"); +} diff --git a/crates/oakstorage/Cargo.toml b/crates/oakstorage/Cargo.toml index d3f1aa847..cfbf67da4 100644 --- a/crates/oakstorage/Cargo.toml +++ b/crates/oakstorage/Cargo.toml @@ -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"] } diff --git a/crates/oakstorage/README.md b/crates/oakstorage/README.md index 7b5fb2dfa..7fe98ed28 100644 --- a/crates/oakstorage/README.md +++ b/crates/oakstorage/README.md @@ -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 +``` diff --git a/crates/oakstorage/src/backend.rs b/crates/oakstorage/src/backend.rs index f842f4967..decd0f873 100644 --- a/crates/oakstorage/src/backend.rs +++ b/crates/oakstorage/src/backend.rs @@ -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; + /// (CHandle with refcount 1) wrapped in a [`LoadResult`], or an + /// error code/context. + fn load(&self, uri: &StorageUri) -> crate::error::Result; /// Save a project to the URI. `options` is the M10 bitmask /// (OAKSTORAGE_SAVE_COMPRESS etc.); unknown bits are ignored. diff --git a/crates/oakstorage/src/backends/database.rs b/crates/oakstorage/src/backends/database.rs index 17a8413e2..a9f5f2414 100644 --- a/crates/oakstorage/src/backends/database.rs +++ b/crates/oakstorage/src/backends/database.rs @@ -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 { + _uri: &crate::uri::StorageUri, + ) -> crate::error::Result { 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!() } diff --git a/crates/oakstorage/src/backends/mod.rs b/crates/oakstorage/src/backends/mod.rs index fa9a94380..641429d45 100644 --- a/crates/oakstorage/src/backends/mod.rs +++ b/crates/oakstorage/src/backends/mod.rs @@ -15,6 +15,10 @@ // along with this program. If not, see . //! 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> { - todo!() + vec![ + Arc::new(ove_xml::OveXmlBackend::new()), + Arc::new(otio::OtioBackend::new()), + ] } diff --git a/crates/oakstorage/src/backends/otio.rs b/crates/oakstorage/src/backends/otio.rs index f06f28ffa..224592efa 100644 --- a/crates/oakstorage/src/backends/otio.rs +++ b/crates/oakstorage/src/backends/otio.rs @@ -14,46 +14,610 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! 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 { - todo!() + fn load(&self, uri: &StorageUri) -> Result { + let path = uri.local_path().ok_or(Error::Invalid)?.to_string(); + let ext = uri.extension().ok_or(Error::Invalid)?; + + let timelines: Vec = 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 = 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 { + 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::()) + { + 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::()); + 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::()); + 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::()) + .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::()) + { + 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) -> 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::()) + { + 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 { + if let Some(be) = entry + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + { + if let Some(f) = be.footage { + if graph.is_valid(f) { + return Some(f); + } + } + } + let mut visited = HashSet::new(); + let mut queue: Vec = 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::()) + .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::() { + return Some((BlockKind::Clip, Some(&c.core))); + } + if let Some(g) = a.downcast_ref::() { + return Some((BlockKind::Gap, Some(&g.core))); + } + if let Some(t) = a.downcast_ref::() { + 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::()) + .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::()) + { + 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::()) + .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::()) + .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::()) + { + 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::()) + { + lb.tracks.push(track_id); + } + } + } + Ok(()) +} diff --git a/crates/oakstorage/src/backends/ove_xml.rs b/crates/oakstorage/src/backends/ove_xml.rs index c919efde9..5b036df50 100644 --- a/crates/oakstorage/src/backends/ove_xml.rs +++ b/crates/oakstorage/src/backends/ove_xml.rs @@ -14,48 +14,141 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! 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 `` +//! document produced by `oaknode::serializer::save`. Historical files +//! carry an `` 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 (`` root, or `` at the build + /// version). + Current, + /// Historical `` 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 { + 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::().ok()); + match root.as_str() { + // Current schema root; the serializer accepts any version attr + // on `` (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 { - todo!() + fn load(&self, uri: &StorageUri) -> crate::error::Result { + 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(()) } } diff --git a/crates/oakstorage/src/bridge/node.rs b/crates/oakstorage/src/bridge/node.rs index 91c8cb591..8901ce463 100644 --- a/crates/oakstorage/src/bridge/node.rs +++ b/crates/oakstorage/src/bridge/node.rs @@ -14,24 +14,157 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! 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>` 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>` 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>; + +/// 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>` (created by this bridge, +/// `oaknode_project_init`, or `oakstorage_project_take_project`). +pub unsafe fn project_arc(h: &CHandle) -> Result { + unsafe { oaknode::handle::get::(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 { + 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 { + 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) { + 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::()) + { + 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 } diff --git a/crates/oakstorage/src/ffi.rs b/crates/oakstorage/src/ffi.rs index f4b03cdfa..27b1531f1 100644 --- a/crates/oakstorage/src/ffi.rs +++ b/crates/oakstorage/src/ffi.rs @@ -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 = 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; + 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::(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 { + let rb = session.ctx as *mut crate::handle::RefBox; + 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 { + unsafe { + let sess = crate::handle::get::(&session).ok_or(Error::Invalid)?; + let ph = sess.project().ok_or(Error::State)?; + let arc = oaknode::handle::get::(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) } { + 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 { + 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() } diff --git a/crates/oakstorage/src/handle.rs b/crates/oakstorage/src/handle.rs index 232fc4499..3d2eb2cfd 100644 --- a/crates/oakstorage/src/handle.rs +++ b/crates/oakstorage/src/handle.rs @@ -14,11 +14,24 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! 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 { 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, - /// Atomic decrement; destroys at zero. - pub release: Option, - /// 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(ctx: *mut std::ffi::c_void) { + unsafe { + let rb = ctx as *const RefBox; + // 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(ctx: *mut std::ffi::c_void) { + unsafe { + let rb = ctx as *mut RefBox; + // 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(ctx: *mut std::ffi::c_void) { + unsafe { + let rb = ctx as *mut RefBox; + 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(value: T) -> CHandle { - todo!() +pub fn make_owned(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::), + release: Some(refbox_release_owned::), + 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( + 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::), + 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(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(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::), + release: Some(refbox_release_borrowed::), + 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(h: &CHandle) -> Option<&T> { - todo!() +pub unsafe fn get(h: &CHandle) -> Option<&T> { + if h.ctx.is_null() { + return None; + } + unsafe { Some(&(*(h.ctx as *const RefBox)).value) } } /// Panic-catching FFI wrapper for i32-returning exports. +/// +/// Panics map to [`OAKSTORAGE_E_FAILED`]. pub fn guard 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 crate::error::Result>(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: F) { - todo!() + let _ = catch_unwind(AssertUnwindSafe(f)); } diff --git a/crates/oakstorage/src/registry.rs b/crates/oakstorage/src/registry.rs index bc10a63ba..a8cc4a8bc 100644 --- a/crates/oakstorage/src/registry.rs +++ b/crates/oakstorage/src/registry.rs @@ -15,10 +15,16 @@ // along with this program. If not, see . //! 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 = 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) -> crate::error::Result<()> { - todo!() + pub fn register(&self, backend: Arc) -> 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> { - todo!() + pub fn resolve(&self, uri: &StorageUri) -> Result> { + 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(m: &Mutex) -> 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 { + Ok(LoadResult::success(CHandle::null())) + } + fn save(&self, _project: CHandle, _uri: &StorageUri, _options: u32) -> Result<()> { + Ok(()) + } + } + + #[test] + fn builtins_are_preregistered() { + let names: Vec = { + 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 { + 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 { + 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"); } } diff --git a/crates/oakstorage/src/session.rs b/crates/oakstorage/src/session.rs index 04a32d9e5..6736d0133 100644 --- a/crates/oakstorage/src/session.rs +++ b/crates/oakstorage/src/session.rs @@ -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 { - 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) }; + } + } } } diff --git a/crates/oakstorage/src/uri.rs b/crates/oakstorage/src/uri.rs index fa26c7796..f3e317c92 100644 --- a/crates/oakstorage/src/uri.rs +++ b/crates/oakstorage/src/uri.rs @@ -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 { - 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 { - 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); + } } } diff --git a/crates/oakstorage/tests/storage_test.rs b/crates/oakstorage/tests/storage_test.rs index bd89a26c2..5c87c98e8 100644 --- a/crates/oakstorage/tests/storage_test.rs +++ b/crates/oakstorage/tests/storage_test.rs @@ -15,62 +15,1020 @@ // along with this program. If not, see . //! oakstorage contract tests (M10 §4 mapping). +//! +//! The oaknode serializer persists the node core surface (types, +//! values, keyframes, label/color, links, connections), settings, uuid, +//! AND the timeline structure (sequence track lists, track blocks, +//! block spans, clip footage references) — the .ove tests assert the +//! full surface; the otio/fcpxml interchange tests exercise the +//! oakotio model. -/// Byte-exact round-trip: open a golden .ove, save to a temp URI, the -/// two files are byte-identical; reopening yields a non-empty project. -#[test] -fn ove_xml_roundtrip_byte_exact() { - todo!() +use std::ffi::{c_char, c_int, c_uint, CStr, CString}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use oakcore_rs::Rational; +use oaknode::block::ClipBlockBehavior; +use oaknode::footage::FootageBehavior; +use oaknode::id::NodeId; +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::NodeValue; +use oakstorage::bridge::node; +use oakstorage::error::{ + OAKSTORAGE_E_FORMAT, OAKSTORAGE_E_INVALID, OAKSTORAGE_E_IO, OAKSTORAGE_E_NO_BACKEND, + OAKSTORAGE_E_STATE, OAKSTORAGE_OK, OAKSTORAGE_TOO_NEW, OAKSTORAGE_TOO_OLD, + OAKSTORAGE_UNKNOWN_VERSION, +}; +use oakstorage::ffi; +use oakstorage::handle::CHandle; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn cs(s: &str) -> CString { + CString::new(s).unwrap() } -/// Compressed .ove round-trip (OAKSTORAGE_SAVE_COMPRESS) likewise -/// loads to the identical project content. -#[test] -fn ove_xml_compressed_roundtrip() { - todo!() +fn file_uri(path: &Path) -> String { + format!("file://{}", path.display()) } -/// probe: .ove (plain/compressed), .otio, unknown scheme → -/// E_NO_BACKEND; NULL/empty URI → E_INVALID. +/// A fresh, unique temp directory for one test. +fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "oakstorage_it_{}_{}", + std::process::id(), + tag + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn alive() -> i32 { + unsafe { ffi::oakstorage_debug_alive_count() } +} + +fn last_error() -> String { + let needed = unsafe { ffi::oakstorage_last_error(std::ptr::null_mut(), 0) }; + if needed <= 0 { + return String::new(); + } + let mut buf = vec![0u8; needed as usize]; + unsafe { ffi::oakstorage_last_error(buf.as_mut_ptr() as *mut c_char, needed) }; + buf.pop(); // trailing NUL + String::from_utf8(buf).unwrap() +} + +/// Probe for the backend name; `Err(code)` when no backend claims it. +fn probe_name(uri: &str) -> Result { + let c = cs(uri); + let needed = unsafe { ffi::oakstorage_probe(c.as_ptr(), std::ptr::null_mut(), 0) }; + if needed < 0 { + return Err(needed); + } + let mut buf = vec![0u8; needed as usize]; + let rc = unsafe { ffi::oakstorage_probe(c.as_ptr(), buf.as_mut_ptr() as *mut c_char, needed) }; + assert!(rc >= 0, "two-stage probe misbehaved"); + buf.pop(); + Ok(String::from_utf8(buf).unwrap()) +} + +fn open(uri: &str) -> (CHandle, c_int) { + let c = cs(uri); + let mut rc = 0; + let session = unsafe { ffi::oakstorage_open(c.as_ptr(), &mut rc) }; + (session, rc) +} + +fn free_session(mut s: CHandle) { + unsafe { ffi::oakstorage_project_free(&mut s) }; +} + +fn free_project(mut h: CHandle) { + node::project_free(&mut h); +} + +fn session_uri(s: &CHandle) -> String { + let needed = unsafe { ffi::oakstorage_project_uri(*s, std::ptr::null_mut(), 0) }; + assert!(needed >= 0, "project_uri failed: {needed}"); + let mut buf = vec![0u8; needed as usize]; + let rc = unsafe { ffi::oakstorage_project_uri(*s, buf.as_mut_ptr() as *mut c_char, needed) }; + assert!(rc >= 0); + buf.pop(); + String::from_utf8(buf).unwrap() +} + +fn r_to_f(r: Rational) -> f64 { + r.numerator() as f64 / r.denominator() as f64 +} + +fn assert_close(a: f64, b: f64) { + assert!((a - b).abs() < 1e-6, "expected {a} close to {b}"); +} + +// --------------------------------------------------------------------------- +// .ove round-trip +// --------------------------------------------------------------------------- + +const MATH: &str = "org.olivevideoeditor.Olive.math"; + +/// Build the round-trip fixture: root folder + two math nodes with +/// values/keyframes/label/color/link/connection + settings. +fn build_roundtrip_project() -> Arc> { + let project = Project::new(); + let mut p = project.lock().unwrap(); + p.initialize().unwrap(); + + let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)(); + let a = p.graph.add_node(core, behavior); + { + let e = p.graph.get_mut(a).unwrap(); + e.core.label = "Math A".to_string(); + e.core.override_color = 2; + e.core.set_standard_value("param_a_in", -1, NodeValue::Float(2.5)); + e.core + .keyframe_track_mut("param_a_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), + }); + e.core + .keyframe_track_mut("param_a_in", -1) + .set_key(Keyframe { + time: Rational::new(1, 1), + value: NodeValue::Float(3.0), + interpolation: Interpolation::Bezier, + bezier_in: (0.1, 0.2), + bezier_out: (0.3, 0.4), + }); + } + let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)(); + let b = p.graph.add_node(core, behavior); + p.graph + .get_mut(b) + .unwrap() + .core + .set_standard_value("param_a_in", -1, NodeValue::Float(4.0)); + + p.graph.connect(a, b, "param_b_in", -1).unwrap(); + p.graph.link(a, b); + + p.settings + .insert("projectname".to_string(), "roundtrip-fixture".to_string()); + drop(p); + project +} + +/// Compare the round-trip-able surface field by field. +fn assert_roundtrip_fields(orig: &Project, loaded: &Project) { + assert_eq!(loaded.uuid, orig.uuid, "uuid"); + assert_eq!(loaded.settings, orig.settings, "settings"); + + let o_ids = orig.graph.node_ids(); + let l_ids = loaded.graph.node_ids(); + assert_eq!(l_ids.len(), o_ids.len(), "node count"); + // Slot order is preserved by the writer, so the ids line up. + let o_types: Vec<&str> = o_ids + .iter() + .map(|id| orig.graph.get(*id).unwrap().behavior.type_id()) + .collect(); + let l_types: Vec<&str> = l_ids + .iter() + .map(|id| loaded.graph.get(*id).unwrap().behavior.type_id()) + .collect(); + assert_eq!(l_types, o_types, "node types"); + + let a_o = o_ids[1]; + let a_l = l_ids[1]; + let b_o = o_ids[2]; + let b_l = l_ids[2]; + + // Label + color. + assert_eq!( + loaded.graph.get(a_l).unwrap().core.label, + orig.graph.get(a_o).unwrap().core.label, + "label" + ); + assert_eq!( + loaded.graph.get(a_l).unwrap().core.override_color, + orig.graph.get(a_o).unwrap().core.override_color, + "color" + ); + + // Standard values. + assert_eq!( + loaded.graph.get(a_l).unwrap().core.standard_value("param_a_in", -1), + orig.graph.get(a_o).unwrap().core.standard_value("param_a_in", -1), + "value a" + ); + assert_eq!( + loaded.graph.get(b_l).unwrap().core.standard_value("param_a_in", -1), + orig.graph.get(b_o).unwrap().core.standard_value("param_a_in", -1), + "value b" + ); + + // Keyframes: count, times, values, interpolation, bezier handles. + let keys_o = orig + .graph + .get(a_o) + .unwrap() + .core + .keyframe_track("param_a_in", -1) + .unwrap() + .keys() + .to_vec(); + let keys_l = loaded + .graph + .get(a_l) + .unwrap() + .core + .keyframe_track("param_a_in", -1) + .unwrap() + .keys() + .to_vec(); + assert_eq!(keys_l.len(), keys_o.len(), "keyframe count"); + for (ko, kl) in keys_o.iter().zip(&keys_l) { + 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"); + } + + // Connection a -> b.param_b_in and the link. + assert_eq!( + loaded.graph.connected_output(b_l, "param_b_in", -1), + Some(a_l), + "connection" + ); + assert!(loaded.graph.are_linked(a_l, b_l), "link"); +} + +#[test] +fn ove_xml_roundtrip_field_by_field() { + let dir = temp_dir("ove_roundtrip"); + let path = dir.join("roundtrip.ove"); + let uri = file_uri(&path); + + let project = build_roundtrip_project(); + let handle = node::make_project_owned(project.clone()); + let rc = unsafe { ffi::oakstorage_save(handle, cs(&uri).as_ptr(), 0) }; + assert_eq!(rc, OAKSTORAGE_OK, "save: {}", last_error()); + free_project(handle); + + // The file exists and is plain XML. + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.starts_with(""), "{text}"); + + let (session, rc) = open(&uri); + assert!(!session.ctx.is_null(), "open failed rc={rc}: {}", last_error()); + assert_eq!(rc, OAKSTORAGE_OK); + assert_eq!(session_uri(&session), uri); + + let proj_handle = unsafe { ffi::oakstorage_project_project(session) }; + assert!(!proj_handle.ctx.is_null()); + let loaded = unsafe { node::project_arc(&proj_handle) }.unwrap(); + { + let o = project.lock().unwrap(); + let l = loaded.lock().unwrap(); + assert_roundtrip_fields(&o, &l); + } + + free_project(proj_handle); + free_session(session); +} + +#[test] +fn ove_xml_compress_flag_still_round_trips() { + // OAKSTORAGE_SAVE_COMPRESS is accepted but not implemented (the + // oaknode serializer emits plain XML); the file still round-trips. + let dir = temp_dir("ove_compress"); + let path = dir.join("compressed.ove"); + let uri = file_uri(&path); + + let project = build_roundtrip_project(); + let handle = node::make_project_owned(project.clone()); + let rc = unsafe { ffi::oakstorage_save(handle, cs(&uri).as_ptr(), ffi::OAKSTORAGE_SAVE_COMPRESS) }; + assert_eq!(rc, OAKSTORAGE_OK, "save: {}", last_error()); + free_project(handle); + + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.starts_with(" footage references (this surface did not +/// round-trip before the timeline serialization landed). +#[test] +fn ove_xml_timeline_roundtrip() { + use oaknode::block::ClipBlockBehavior; + use oaknode::footage::FootageBehavior; + use oaknode::sequence::SequenceBehavior; + use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType}; + + let dir = temp_dir("ove_timeline"); + let path = dir.join("timeline.ove"); + let uri = file_uri(&path); + + let project = build_timeline_project(); + let handle = node::make_project_owned(project.clone()); + let rc = unsafe { ffi::oakstorage_save(handle, cs(&uri).as_ptr(), 0) }; + assert_eq!(rc, OAKSTORAGE_OK, "save: {}", last_error()); + free_project(handle); + + let (session, rc) = open(&uri); + assert!(!session.ctx.is_null(), "open failed rc={rc}: {}", last_error()); + assert_eq!(rc, OAKSTORAGE_OK); + let proj_handle = unsafe { ffi::oakstorage_project_project(session) }; + let loaded = unsafe { node::project_arc(&proj_handle) }.unwrap(); + { + let l = loaded.lock().unwrap(); + assert_imported_timeline(&l); + } + + // The bin-level detail too: the sequence keeps its three track + // lists (video/audio/subtitle) with the right kinds and bases. + { + let l = loaded.lock().unwrap(); + let mut seq: Option<(NodeId, Vec)> = None; + for id in l.graph.node_ids() { + let entry = l.graph.get(id).unwrap(); + if entry.behavior.type_id() == "org.olivevideoeditor.Olive.sequence" { + let s = entry + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + seq = Some((id, s.track_lists.clone())); + break; + } + } + let (_seq_id, lists) = seq.expect("sequence present"); + assert_eq!(lists.len(), 3, "video/audio/subtitle lists"); + let vlist = lists[0]; + let list = l + .graph + .get(vlist) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_eq!(list.kind, TrackType::Video); + assert_eq!(list.array_base, 0); + assert_eq!(list.tracks.len(), 1); + let track = l + .graph + .get(list.tracks[0]) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_eq!(track.kind, TrackType::Video); + assert_eq!(track.blocks.len(), 2); + let c1 = l + .graph + .get(track.blocks[0]) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_close(r_to_f(c1.core.length()), 4.0); + assert_close(r_to_f(c1.core.media_in), 0.0); + let f1 = l + .graph + .get(c1.footage.unwrap()) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_eq!(f1.filename, "/a/b.mp4"); + let c2 = l + .graph + .get(track.blocks[1]) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_close(r_to_f(c2.core.length()), 2.0); + assert_close(r_to_f(c2.core.media_in), 0.4); + let f2 = l + .graph + .get(c2.footage.unwrap()) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_eq!(f2.filename, "/a/c.mp4"); + } + free_project(proj_handle); + free_session(session); +} + +// --------------------------------------------------------------------------- +// Probe dispatch +// --------------------------------------------------------------------------- + #[test] fn probe_dispatch() { - todo!() + assert_eq!(probe_name("file:///tmp/x.ove").unwrap(), "ove-xml"); + assert_eq!(probe_name("file:///tmp/x.OTIO").unwrap(), "otio", "case-insensitive"); + assert_eq!(probe_name("file:///tmp/x.fcpxml").unwrap(), "otio"); + assert_eq!(probe_name("/tmp/x.ove").unwrap(), "ove-xml", "bare path"); + + // No backend claims these. + assert_eq!(probe_name("oakdb://user@host/db").unwrap_err(), OAKSTORAGE_E_NO_BACKEND); + assert_eq!(probe_name("file:///tmp/x.txt").unwrap_err(), OAKSTORAGE_E_NO_BACKEND); + + // NULL / empty URI -> E_INVALID. + let rc = unsafe { ffi::oakstorage_probe(std::ptr::null(), std::ptr::null_mut(), 0) }; + assert_eq!(rc, OAKSTORAGE_E_INVALID); + assert_eq!(probe_name("").unwrap_err(), OAKSTORAGE_E_INVALID); } -/// Error paths: nonexistent file → open fails with last_error set; -/// too-new version header → OAKSTORAGE_TOO_NEW; corrupt XML → -/// E_FORMAT. +// --------------------------------------------------------------------------- +// Open error paths and version info codes +// --------------------------------------------------------------------------- + #[test] fn open_error_paths() { - todo!() + // Nonexistent file: NULL + E_IO + a non-empty last_error. + let (session, rc) = open(&file_uri(&temp_dir("ove_missing").join("nope.ove"))); + assert!(session.ctx.is_null()); + assert_eq!(rc, OAKSTORAGE_E_IO); + assert!(!last_error().is_empty(), "last_error must be set"); + + // Too-new version header -> TOO_NEW info code, no project. + let dir = temp_dir("ove_versions"); + let future = dir.join("future.ove"); + std::fs::write(&future, "").unwrap(); + let (session, rc) = open(&file_uri(&future)); + assert!(session.ctx.is_null()); + assert_eq!(rc, OAKSTORAGE_TOO_NEW); + + // Corrupt XML -> E_FORMAT. + let corrupt = dir.join("corrupt.ove"); + std::fs::write(&corrupt, "").unwrap(); + let (session, rc) = open(&file_uri(&corrupt)); + assert!(session.ctx.is_null()); + assert_eq!(rc, OAKSTORAGE_E_FORMAT); + + // Unparseable garbage -> E_FORMAT (not a version info code). + let garbage = dir.join("garbage.ove"); + std::fs::write(&garbage, "not xml at all").unwrap(); + let (session, rc) = open(&file_uri(&garbage)); + assert!(session.ctx.is_null()); + assert_eq!(rc, OAKSTORAGE_E_FORMAT); + + // Recognized olive root without a version -> UNKNOWN_VERSION. + let unversioned = dir.join("unversioned.ove"); + std::fs::write(&unversioned, "").unwrap(); + let (session, rc) = open(&file_uri(&unversioned)); + assert!(session.ctx.is_null()); + assert_eq!(rc, OAKSTORAGE_UNKNOWN_VERSION); + + // A known older version loads, reporting TOO_OLD. + let old = dir.join("old.ove"); + std::fs::write( + &old, + "{old}", + ) + .unwrap(); + let (session, rc) = open(&file_uri(&old)); + assert!(!session.ctx.is_null(), "old version must load"); + assert_eq!(rc, OAKSTORAGE_TOO_OLD); + free_session(session); + + // NULL URI -> E_INVALID. + let (session, rc) = unsafe { + let c = std::ptr::null(); + let mut rc = 0; + (ffi::oakstorage_open(c, &mut rc), rc) + }; + assert!(session.ctx.is_null()); + assert_eq!(rc, OAKSTORAGE_E_INVALID); +} + +// --------------------------------------------------------------------------- +// Backend vtable pluggability (the database-swap interface proof) +// --------------------------------------------------------------------------- + +/// Record of the mock backend's save calls. +static MOCK_SAVED: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +unsafe extern "C" fn mock_can_handle(uri: *const c_char) -> c_int { + let s = unsafe { CStr::from_ptr(uri) }.to_str().unwrap_or(""); + (s.starts_with("mem://")) as c_int +} + +unsafe extern "C" fn mock_load( + uri: *const c_char, + result_code: *mut c_int, + _err_buf: *mut c_char, + _err_buf_size: c_int, +) -> CHandle { + let s = unsafe { CStr::from_ptr(uri) }.to_str().unwrap_or(""); + MOCK_SAVED.lock().unwrap().push(format!("load:{s}")); + if !result_code.is_null() { + unsafe { *result_code = OAKSTORAGE_OK }; + } + // A real project handle is the "loaded project". + node::project_init() +} + +unsafe extern "C" fn mock_save( + _project: CHandle, + uri: *const c_char, + options: c_uint, + _err_buf: *mut c_char, + _err_buf_size: c_int, +) -> c_int { + let s = unsafe { CStr::from_ptr(uri) }.to_str().unwrap_or("").to_string(); + MOCK_SAVED.lock().unwrap().push(format!("save:{s}:options={options}")); + OAKSTORAGE_OK } -/// Pluggability proof (M10 §4): register a mock `mem://` backend, -/// probe/open/save all route through its vtable; after unregister, -/// probe reports E_NO_BACKEND. This test IS the database-swap -/// interface verification. #[test] fn backend_vtable_pluggability() { - todo!() + let name = cs("mem-test"); + let scheme = cs("mem"); + let vtable = ffi::OakStorageBackendVtable { + name: name.as_ptr(), + uri_scheme: scheme.as_ptr(), + can_handle: Some(mock_can_handle), + load: Some(mock_load), + save: Some(mock_save), + }; + + assert_eq!( + unsafe { ffi::oakstorage_backend_register(&vtable) }, + OAKSTORAGE_OK + ); + + // Probe routes through the foreign vtable. + assert_eq!(probe_name("mem://x").unwrap(), "mem-test"); + + // Open routes through the vtable's load. + let (session, rc) = open("mem://in"); + assert!(!session.ctx.is_null(), "open failed rc={rc}"); + assert_eq!(rc, OAKSTORAGE_OK); + free_session(session); + + // Save routes through the vtable's save, options passed through. + let project = node::project_init(); + let rc = unsafe { ffi::oakstorage_save(project, cs("mem://out").as_ptr(), ffi::OAKSTORAGE_SAVE_COMPRESS) }; + assert_eq!(rc, OAKSTORAGE_OK, "save: {}", last_error()); + free_project(project); + { + let saved = MOCK_SAVED.lock().unwrap(); + assert!(saved.iter().any(|s| s == "load:mem://in"), "{saved:?}"); + assert!( + saved + .iter() + .any(|s| s == "save:mem://out:options=1"), + "{saved:?}" + ); + } + + // Duplicate registration is rejected. + assert_eq!( + unsafe { ffi::oakstorage_backend_register(&vtable) }, + OAKSTORAGE_E_STATE + ); + + // Unregister: probe reports E_NO_BACKEND again. + assert_eq!(unsafe { ffi::oakstorage_backend_unregister(name.as_ptr()) }, OAKSTORAGE_OK); + assert_eq!(probe_name("mem://x").unwrap_err(), OAKSTORAGE_E_NO_BACKEND); + + // Unregister of an unknown name -> E_NOT_FOUND. + assert_eq!( + unsafe { ffi::oakstorage_backend_unregister(cs("nope").as_ptr()) }, + oakstorage::error::OAKSTORAGE_E_NOT_FOUND + ); +} + +// --------------------------------------------------------------------------- +// otio / fcpxml interchange round-trip +// --------------------------------------------------------------------------- + +/// Build the timeline fixture: a sequence "My Seq" with one video track +/// carrying two clips (footage /a/b.mp4 and /a/c.mp4). +fn build_timeline_project() -> Arc> { + let project = Project::new(); + let mut p = project.lock().unwrap(); + p.initialize().unwrap(); + + let (seq_id, lists) = node::create_sequence(&mut p.graph); + p.graph.get_mut(seq_id).unwrap().core.label = "My Seq".to_string(); + + let mut tb = TrackBehavior::new(TrackType::Video); + tb.track_list = Some(lists[0]); + let track_id = p.graph.add_node(NodeCore::new(), Box::new(tb)); + + // Clip 1: 100/25 long, media in 0/1, footage /a/b.mp4. + let foot1 = p + .graph + .add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/b.mp4"))); + let clip1 = { + let (core, mut behavior) = oaknode::block::clip_create(); + let clip = behavior + .as_any_mut() + .and_then(|a| a.downcast_mut::()) + .unwrap(); + clip.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(100, 25)); + clip.core.media_in = Rational::new(0, 1); + clip.core.track = Some(track_id); + clip.footage = Some(foot1); + p.graph.add_node(core, behavior) + }; + + // Clip 2: 50/25 long, media in 10/25, footage /a/c.mp4. + let foot2 = p + .graph + .add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/c.mp4"))); + let clip2 = { + let (core, mut behavior) = oaknode::block::clip_create(); + let clip = behavior + .as_any_mut() + .and_then(|a| a.downcast_mut::()) + .unwrap(); + clip.core.range = oakcore_rs::TimeRange::new(Rational::new(100, 25), Rational::new(150, 25)); + clip.core.media_in = Rational::new(10, 25); + clip.core.track = Some(track_id); + clip.footage = Some(foot2); + p.graph.add_node(core, behavior) + }; + + if let Some(entry) = p.graph.get_mut(track_id) { + entry + .behavior + .as_any_mut() + .and_then(|a| a.downcast_mut::()) + .unwrap() + .blocks = vec![clip1, clip2]; + } + if let Some(entry) = p.graph.get_mut(lists[0]) { + entry + .behavior + .as_any_mut() + .and_then(|a| a.downcast_mut::()) + .unwrap() + .tracks = vec![track_id]; + } + drop(p); + project +} + +/// Assertions over the otio model of a written interchange file. +fn assert_otio_model(timelines: &[oakotio::Timeline]) { + assert_eq!(timelines.len(), 1); + let timeline = &timelines[0]; + assert_eq!(timeline.name(), "My Seq"); + let tracks: Vec<&oakotio::Track> = timeline + .tracks() + .children() + .iter() + .filter_map(|c| c.as_track()) + .collect(); + assert_eq!(tracks.len(), 1, "one video track"); + let track = tracks[0]; + assert_eq!(track.kind(), "Video"); + let clips: Vec<&oakotio::Clip> = track + .children() + .iter() + .filter_map(|c| c.as_clip()) + .collect(); + assert_eq!(clips.len(), 2, "two clips"); + + assert_eq!(clips[0].name(), "b"); + assert_eq!( + clips[0] + .media_reference() + .unwrap() + .as_external_reference() + .unwrap() + .target_url(), + "file:///a/b.mp4" + ); + let r0 = clips[0].source_range().unwrap(); + assert_close(r0.duration().to_seconds(), 4.0); + assert_close(r0.start_time().to_seconds(), 0.0); + + assert_eq!(clips[1].name(), "c"); + assert_eq!( + clips[1] + .media_reference() + .unwrap() + .as_external_reference() + .unwrap() + .target_url(), + "file:///a/c.mp4" + ); + let r1 = clips[1].source_range().unwrap(); + assert_close(r1.duration().to_seconds(), 2.0); + assert_close(r1.start_time().to_seconds(), 0.4); +} + +/// Assertions over the oaknode project imported from an interchange +/// file: one sequence, one video track, two clips with footage. +fn assert_imported_timeline(project: &Project) { + // Find the sequence and its track lists. + let mut seq: Option<(NodeId, Vec)> = None; + for id in project.graph.node_ids() { + let entry = project.graph.get(id).unwrap(); + if entry.behavior.type_id() == "org.olivevideoeditor.Olive.sequence" { + let s = entry + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + seq = Some((id, s.track_lists.clone())); + break; + } + } + let (seq_id, lists) = seq.expect("imported project has a sequence"); + assert_eq!(project.graph.get(seq_id).unwrap().core.label, "My Seq"); + + // Video list -> one track. + let list_entry = project.graph.get(lists[0]).unwrap(); + let list = list_entry + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_eq!(list.tracks.len(), 1); + let track_id = list.tracks[0]; + let track = project + .graph + .get(track_id) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_eq!(track.kind, TrackType::Video); + assert_eq!(track.blocks.len(), 2); + + // Clip 1. + let c1 = project + .graph + .get(track.blocks[0]) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .expect("block 0 is a clip"); + assert_close(r_to_f(c1.core.length()), 4.0); + assert_close(r_to_f(c1.core.media_in), 0.0); + let f1 = c1.footage.expect("clip 1 has footage"); + let filename1 = project + .graph + .get(f1) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap() + .filename + .clone(); + assert_eq!(filename1, "/a/b.mp4"); + + // Clip 2. + let c2 = project + .graph + .get(track.blocks[1]) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .expect("block 1 is a clip"); + assert_close(r_to_f(c2.core.length()), 2.0); + assert_close(r_to_f(c2.core.media_in), 0.4); + let f2 = c2.footage.expect("clip 2 has footage"); + let filename2 = project + .graph + .get(f2) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap() + .filename + .clone(); + assert_eq!(filename2, "/a/c.mp4"); +} + +fn assert_interchange_roundtrip(ext: &str) { + let dir = temp_dir(&format!("interchange_{ext}")); + let path = dir.join(format!("timeline.{ext}")); + let uri = file_uri(&path); + + let project = build_timeline_project(); + let handle = node::make_project_owned(project.clone()); + let rc = unsafe { ffi::oakstorage_save(handle, cs(&uri).as_ptr(), 0) }; + assert_eq!(rc, OAKSTORAGE_OK, "save: {}", last_error()); + free_project(handle); + + // The written file parses back through oakotio with clips/tracks. + match ext { + "otio" => { + let text = std::fs::read_to_string(&path).unwrap(); + let root = oakotio::from_json_string(&text).unwrap(); + let timelines: Vec = match root { + oakotio::Serializable::Timeline(t) => vec![t], + other => panic!("expected a timeline root, got {}", other.schema_name()), + }; + assert_otio_model(&timelines); + } + "fcpxml" => { + let text = std::fs::read_to_string(&path).unwrap(); + let timelines = oakotio::from_fcpxml_string(&text).unwrap(); + assert_otio_model(&timelines); + } + _ => unreachable!(), + } + + // And imports back into an equivalent project. + let (session, rc) = open(&uri); + assert!(!session.ctx.is_null(), "open failed rc={rc}: {}", last_error()); + assert_eq!(rc, OAKSTORAGE_OK); + let proj_handle = unsafe { ffi::oakstorage_project_project(session) }; + let loaded = unsafe { node::project_arc(&proj_handle) }.unwrap(); + { + let l = loaded.lock().unwrap(); + assert_imported_timeline(&l); + } + free_project(proj_handle); + free_session(session); } -/// SQLite backend: save → load round-trip through -/// `oakdb+sqlite:///…` yields the same project payload as ove-xml -/// (one serialization truth). #[test] -fn sqlite_roundtrip() { - todo!() +fn otio_roundtrip() { + assert_interchange_roundtrip("otio"); } -/// PostgreSQL backend: same round-trip against a local test database; -/// skipped when no PG is reachable (env-gated). #[test] -fn postgres_roundtrip() { - todo!() +fn fcpxml_roundtrip() { + assert_interchange_roundtrip("fcpxml"); } -/// alive count: open/take/free pairing leaves no leak. +/// A file whose root is neither a timeline nor a collection fails with +/// E_FORMAT on open. +#[test] +fn otio_bad_root_rejected() { + let dir = temp_dir("otio_badroot"); + let path = dir.join("bad.otio"); + std::fs::write(&path, "{\"OTIO_SCHEMA\": \"NotATimeline.1\", \"x\": 1}").unwrap(); + let (session, rc) = open(&file_uri(&path)); + assert!(session.ctx.is_null()); + assert_eq!(rc, OAKSTORAGE_E_FORMAT); +} + +// --------------------------------------------------------------------------- +// NULL / invalid-handle matrix +// --------------------------------------------------------------------------- + +#[test] +fn null_and_invalid_handles() { + // open with NULL / empty URI -> E_INVALID. + let mut rc = 0; + let session = unsafe { ffi::oakstorage_open(std::ptr::null(), &mut rc) }; + assert!(session.ctx.is_null()); + assert_eq!(rc, OAKSTORAGE_E_INVALID); + + // save with a null project handle -> E_INVALID. + let rc = unsafe { ffi::oakstorage_save(CHandle::null(), cs("file:///tmp/x.ove").as_ptr(), 0) }; + assert_eq!(rc, OAKSTORAGE_E_INVALID); + + // save with a null URI -> E_INVALID. + let project = node::project_init(); + let rc = unsafe { ffi::oakstorage_save(project, std::ptr::null(), 0) }; + assert_eq!(rc, OAKSTORAGE_E_INVALID); + free_project(project); + + // save to an unknown-scheme URI -> E_NO_BACKEND. + let project = node::project_init(); + let rc = unsafe { ffi::oakstorage_save(project, cs("oakdb://x").as_ptr(), 0) }; + assert_eq!(rc, OAKSTORAGE_E_NO_BACKEND); + free_project(project); + + // project_free(NULL) is a no-op. + unsafe { ffi::oakstorage_project_free(std::ptr::null_mut()) }; + + // take_project / project_project on a null handle -> null. + let h = unsafe { ffi::oakstorage_project_take_project(CHandle::null()) }; + assert!(h.ctx.is_null()); + let h = unsafe { ffi::oakstorage_project_project(CHandle::null()) }; + assert!(h.ctx.is_null()); + + // project_uri on a null handle -> E_INVALID. + let rc = unsafe { ffi::oakstorage_project_uri(CHandle::null(), std::ptr::null_mut(), 0) }; + assert_eq!(rc, OAKSTORAGE_E_INVALID); + + // Empty session shell: take twice yields null the second time. + let dir = temp_dir("null_matrix"); + let path = dir.join("x.ove"); + let uri = file_uri(&path); + let project = build_roundtrip_project(); + let handle = node::make_project_owned(project); + assert_eq!( + unsafe { ffi::oakstorage_save(handle, cs(&uri).as_ptr(), 0) }, + OAKSTORAGE_OK + ); + free_project(handle); + + let (session, _) = open(&uri); + let taken = unsafe { ffi::oakstorage_project_take_project(session) }; + assert!(!taken.ctx.is_null(), "take transfers the project"); + let again = unsafe { ffi::oakstorage_project_take_project(session) }; + assert!(again.ctx.is_null(), "second take is empty"); + let borrowed = unsafe { ffi::oakstorage_project_project(session) }; + assert!(borrowed.ctx.is_null(), "borrowed is empty after take"); + free_project(taken); + free_session(session); + + // backend_register(NULL) / backend_unregister(NULL) -> E_INVALID. + assert_eq!( + unsafe { ffi::oakstorage_backend_register(std::ptr::null()) }, + OAKSTORAGE_E_INVALID + ); + assert_eq!( + unsafe { ffi::oakstorage_backend_unregister(std::ptr::null()) }, + OAKSTORAGE_E_INVALID + ); +} + +// --------------------------------------------------------------------------- +// Alive accounting +// --------------------------------------------------------------------------- + #[test] fn alive_count_accounting() { - todo!() + let dir = temp_dir("alive"); + let path = dir.join("x.ove"); + let uri = file_uri(&path); + let project = build_roundtrip_project(); + let handle = node::make_project_owned(project); + assert_eq!( + unsafe { ffi::oakstorage_save(handle, cs(&uri).as_ptr(), 0) }, + OAKSTORAGE_OK + ); + free_project(handle); + + let before = alive(); + { + let (session, rc) = open(&uri); + assert_eq!(rc, OAKSTORAGE_OK); + assert_eq!(alive(), before + 1, "open counts one session"); + + // Take transfers the project; the session shell stays alive. + let taken = unsafe { ffi::oakstorage_project_take_project(session) }; + assert!(!taken.ctx.is_null()); + assert_eq!(alive(), before + 1, "take keeps the session shell"); + + free_project(taken); + free_session(session); + } + assert_eq!(alive(), before, "open/take/free pair leaves no leak"); + + // A second pairing through project_project (borrowed) as well. + let before = alive(); + { + let (session, _) = open(&uri); + let borrowed = unsafe { ffi::oakstorage_project_project(session) }; + assert!(!borrowed.ctx.is_null()); + free_project(borrowed); + free_session(session); + } + assert_eq!(alive(), before, "borrowed view pairing leaves no leak"); } diff --git a/docs/project-file-reference.md b/docs/project-file-reference.md index 5fd3ea7b7..4f5c19f5f 100644 --- a/docs/project-file-reference.md +++ b/docs/project-file-reference.md @@ -488,6 +488,146 @@ Used by `Footage`, `Sequence`, etc. - ``: pointer id of the node that provides the group's output. - ``:提供组输出的节点指针 ID。 +### 5.5 Rust serializer extensions / Rust 序列化器扩展 + +The Rust serializer (`crates/oaknode/src/serializer.rs`) persists the +timeline structure through the per-node `` 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 序列化器通过各节点的 `` 段持久化时间线结构。C++ 格式用连接编码 +(sequence 的 `track_in_%1` 数组 → 轨道,track 的 `block_in` 数组 → 块); +Rust 模型把层级放在行为结构体中,因此由以下段承载。所有元素都是**新增的**: +旧读取器(C++ `LoadCustom`、`skipCurrentElement`)会跳过它们。 + +`Sequence` (`org.olivevideoeditor.Olive.sequence`): + +```xml + + + ptr + + +``` + +- ``: the video/audio/subtitle `TrackList` node references + (C++ writes workarea/markers here; those are opaque handles in Rust). + - ``:视频/音频/字幕 `TrackList` 节点引用(C++ 在此写 + workarea/markers;Rust 中是透明句柄)。 + +`TrackList` (`org.olivevideoeditor.Olive.tracklist`): + +```xml + + 0 + 0 + ptr + + ptr + + +``` + +- ``: `Track::Type` integer (0 video, 1 audio, 2 subtitle). + - ``:`Track::Type` 整数(0 视频、1 音频、2 字幕)。 +- ``: the sequence `track_in_%1` input index this list owns. + - ``:该列表拥有的 sequence `track_in_%1` 输入下标。 +- ``: the owning sequence node reference. + - ``:所属 sequence 节点引用。 +- ``: the `Track` node references in stack order. + - ``:按栈序排列的 `Track` 节点引用。 + +`Track` (`org.olivevideoeditor.Olive.track`): + +```xml + + 0 + 0 + 0 + 0 + 3 + ptr + + ptr + + +``` + +- ``: the C++ element (internal units); the rest are Rust + additions. When `` is absent (a C++ file), the kind is derived + from the sequence `track_in_%1` connection. + - `` 是 C++ 元素(内部单位);其余为 Rust 新增。当 `` + 缺失(C++ 文件)时,从 sequence `track_in_%1` 连接推断类型。 + +Blocks (`clipblock` / `gapblock` / `transitionblock`): + +```xml + + + 0/1 + 1 + 0 + 1 + 0 + 0 + ptr + + ptr + + 0/1 + 0/1 + +``` + +- ``: the block's timeline span (C++ derives in/out from the + track order; the Rust block owns it). + - ``:块的时间线区间(C++ 从轨道顺序推导 in/out;Rust 块直接持有)。 +- `` / ``: owning track / connected footage references. + - `` / ``:所属轨道 / 关联素材引用。 + +`Footage` (`org.olivevideoeditor.Olive.footage`): the C++ elements +(`timestamp`, `proxy`, `sourcestarttime`, `viewer`) plus: + +```xml + + /path/to/file.mp4 + + + + + + + +``` + +- ``: the media path (C++ stores it in the `file_in` input; + Rust reads either on load). ``: the probed stream table. + - ``:媒体路径(C++ 存在 `file_in` 输入里;Rust 加载时两者都读)。 + ``:探测到的流表。 + +`Folder` (`org.olivevideoeditor.Olive.folder`): + +```xml + + + ptr + + +``` + +- ``: 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. + - ``:素材箱子项(C++ 通过 `child_in` 输入连接挂载;Rust 读取器 + 也把这些连接并入)。folder 节点声明 `child_in` 数组输入以兼容 C++ 文件。 + --- ## 6. `VideoParams` / 视频参数