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
+
+
+
+
+```
+
+- ``: `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
+
+ 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 块直接持有)。
+- `