diff --git a/Cargo.lock b/Cargo.lock index b421730..63e04c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,7 +31,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "aligned" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "377e4c0ba83e4431b10df45c1d4666f178ea9c552cac93e60c3a88bf32785923" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" dependencies = [ "as-slice", ] @@ -57,23 +57,21 @@ dependencies = [ [[package]] name = "android-activity" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.10.0", + "bitflags 2.11.0", "cc", - "cesu8", "jni", - "jni-sys", "libc", "log", "ndk", "ndk-context", "ndk-sys", "num_enum", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -93,9 +91,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arbitrary" @@ -183,7 +181,7 @@ dependencies = [ "num-traits", "pastey 0.1.1", "rayon", - "thiserror 2.0.17", + "thiserror 2.0.18", "v_frame", "y4m", ] @@ -204,9 +202,9 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" dependencies = [ "arrayvec", ] @@ -240,9 +238,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "bitstream-io" @@ -265,7 +263,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2", + "objc2 0.5.2", ] [[package]] @@ -276,15 +274,15 @@ checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" dependencies = [ "bytemuck_derive", ] @@ -308,9 +306,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "calloop" @@ -318,7 +316,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "log", "polling", "rustix 0.38.44", @@ -328,13 +326,13 @@ dependencies = [ [[package]] name = "calloop" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "polling", - "rustix 1.1.2", + "rustix 1.1.4", "slab", "tracing", ] @@ -357,17 +355,17 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ - "calloop 0.14.3", - "rustix 1.1.2", + "calloop 0.14.4", + "rustix 1.1.4", "wayland-backend", "wayland-client", ] [[package]] name = "cc" -version = "1.2.48" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -375,17 +373,11 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-expr" -version = "0.20.4" +version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9acd0bdbbf4b2612d09f52ba61da432140cb10930354079d0d53fafc12968726" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" dependencies = [ "smallvec", "target-lexicon", @@ -418,9 +410,9 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" dependencies = [ - "objc2", + "objc2 0.5.2", "objc2-app-kit", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -434,11 +426,11 @@ dependencies = [ [[package]] name = "clipboard_x11" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4274ea815e013e0f9f04a2633423e14194e408a0576c943ce3d14ca56c50031c" +checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" dependencies = [ - "thiserror 1.0.69", + "thiserror 2.0.18", "x11rb", ] @@ -517,19 +509,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-graphics" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.10.1", - "core-graphics-types 0.2.0", - "foreign-types", - "libc", -] - [[package]] name = "core-graphics-types" version = "0.1.3" @@ -547,7 +526,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.10.1", "libc", ] @@ -576,7 +555,7 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "fontdb", "harfrust", "linebender_resource_handle", @@ -637,7 +616,8 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "cryoglyph" version = "0.1.0" -source = "git+https://github.com/iced-rs/cryoglyph.git?rev=99b46959369f38a06c11353bf1be81d383b289fc#99b46959369f38a06c11353bf1be81d383b289fc" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519" dependencies = [ "cosmic-text", "etagere", @@ -658,6 +638,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -671,9 +661,9 @@ dependencies = [ [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] @@ -695,8 +685,9 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "dpi" -version = "0.1.1" -source = "git+https://github.com/iced-rs/winit.git?rev=05b8ff17a06562f0a10bb46e6eaacbe2a95cb5ed#05b8ff17a06562f0a10bb46e6eaacbe2a95cb5ed" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "either" @@ -758,9 +749,9 @@ dependencies = [ [[package]] name = "euclid" -version = "0.22.11" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", ] @@ -817,15 +808,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -913,9 +904,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -928,9 +919,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -938,33 +929,32 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", "futures-util", - "num_cpus", ] [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -973,21 +963,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -997,7 +987,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1007,7 +996,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.4", "windows-link", ] @@ -1019,15 +1008,28 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", "wasip2", + "wasip3", ] [[package]] name = "gif" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f954a9e9159ec994f73a30a12b96a702dde78f5547bcb561174597924f7d4162" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" dependencies = [ "color_quant", "weezl", @@ -1035,8 +1037,8 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.22.0" -source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#16bf36669c62f048d788b7b141408bbb7a96fee4" +version = "0.23.0-alpha" +source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#050bc0a3b1c869cf112cca0ffb882448428b64b3" dependencies = [ "glib-sys", "gobject-sys", @@ -1064,10 +1066,10 @@ checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" [[package]] name = "glib" -version = "0.22.0" -source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#16bf36669c62f048d788b7b141408bbb7a96fee4" +version = "0.23.0-alpha" +source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#050bc0a3b1c869cf112cca0ffb882448428b64b3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "futures-channel", "futures-core", "futures-executor", @@ -1084,8 +1086,8 @@ dependencies = [ [[package]] name = "glib-macros" -version = "0.22.0" -source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#16bf36669c62f048d788b7b141408bbb7a96fee4" +version = "0.23.0-alpha" +source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#050bc0a3b1c869cf112cca0ffb882448428b64b3" dependencies = [ "heck", "proc-macro2", @@ -1095,8 +1097,8 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.22.0" -source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#16bf36669c62f048d788b7b141408bbb7a96fee4" +version = "0.23.0-alpha" +source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#050bc0a3b1c869cf112cca0ffb882448428b64b3" dependencies = [ "libc", "system-deps", @@ -1125,8 +1127,8 @@ dependencies = [ [[package]] name = "gobject-sys" -version = "0.22.0" -source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#16bf36669c62f048d788b7b141408bbb7a96fee4" +version = "0.23.0-alpha" +source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#050bc0a3b1c869cf112cca0ffb882448428b64b3" dependencies = [ "glib-sys", "libc", @@ -1139,7 +1141,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "gpu-alloc-types", ] @@ -1149,7 +1151,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -1170,7 +1172,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -1181,13 +1183,13 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] name = "gstreamer" -version = "0.25.0" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#d49c01e8d1ea68c6a15ea3520b0f2faa5048b29e" +version = "0.26.0-alpha" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#e8378a2a7cee63610ed0de2ad133497dc92ebd10" dependencies = [ "cfg-if", "futures-channel", @@ -1202,16 +1204,16 @@ dependencies = [ "num-integer", "num-rational", "option-operations", - "pastey 0.2.0", + "pastey 0.2.1", "pin-project-lite", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "gstreamer-app" -version = "0.25.0" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#d49c01e8d1ea68c6a15ea3520b0f2faa5048b29e" +version = "0.26.0-alpha" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#e8378a2a7cee63610ed0de2ad133497dc92ebd10" dependencies = [ "futures-core", "futures-sink", @@ -1224,8 +1226,8 @@ dependencies = [ [[package]] name = "gstreamer-app-sys" -version = "0.25.0" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#d49c01e8d1ea68c6a15ea3520b0f2faa5048b29e" +version = "0.26.0-alpha" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#e8378a2a7cee63610ed0de2ad133497dc92ebd10" dependencies = [ "glib-sys", "gstreamer-base-sys", @@ -1236,8 +1238,8 @@ dependencies = [ [[package]] name = "gstreamer-base" -version = "0.25.0" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#d49c01e8d1ea68c6a15ea3520b0f2faa5048b29e" +version = "0.26.0-alpha" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#e8378a2a7cee63610ed0de2ad133497dc92ebd10" dependencies = [ "atomic_refcell", "cfg-if", @@ -1249,8 +1251,8 @@ dependencies = [ [[package]] name = "gstreamer-base-sys" -version = "0.25.0" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#d49c01e8d1ea68c6a15ea3520b0f2faa5048b29e" +version = "0.26.0-alpha" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#e8378a2a7cee63610ed0de2ad133497dc92ebd10" dependencies = [ "glib-sys", "gobject-sys", @@ -1261,8 +1263,8 @@ dependencies = [ [[package]] name = "gstreamer-sys" -version = "0.25.0" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#d49c01e8d1ea68c6a15ea3520b0f2faa5048b29e" +version = "0.26.0-alpha" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#e8378a2a7cee63610ed0de2ad133497dc92ebd10" dependencies = [ "cfg-if", "glib-sys", @@ -1273,8 +1275,8 @@ dependencies = [ [[package]] name = "gstreamer-video" -version = "0.25.0" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#d49c01e8d1ea68c6a15ea3520b0f2faa5048b29e" +version = "0.26.0-alpha" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#e8378a2a7cee63610ed0de2ad133497dc92ebd10" dependencies = [ "cfg-if", "futures-channel", @@ -1283,13 +1285,13 @@ dependencies = [ "gstreamer-base", "gstreamer-video-sys", "libc", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "gstreamer-video-sys" -version = "0.25.0" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#d49c01e8d1ea68c6a15ea3520b0f2faa5048b29e" +version = "0.26.0-alpha" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#e8378a2a7cee63610ed0de2ad133497dc92ebd10" dependencies = [ "glib-sys", "gobject-sys", @@ -1327,7 +1329,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytemuck", "core_maths", "read-fonts", @@ -1372,8 +1374,8 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" [[package]] name = "iced" -version = "0.14.0-dev" -source = "git+https://github.com/Lowband21/iced-ferrex.git#106ad05e21e624def9cde43aab3e5f40d3a95fd2" +version = "0.14.0" +source = "git+https://github.com/Lowband21/iced-ferrex.git#577abb7fa132ecd160adb5c8dfaf5c187b4f888d" dependencies = [ "iced_core", "iced_debug", @@ -1383,15 +1385,15 @@ dependencies = [ "iced_widget", "iced_winit", "image", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "iced_core" -version = "0.14.0-dev" -source = "git+https://github.com/Lowband21/iced-ferrex.git#106ad05e21e624def9cde43aab3e5f40d3a95fd2" +version = "0.14.0" +source = "git+https://github.com/Lowband21/iced-ferrex.git#577abb7fa132ecd160adb5c8dfaf5c187b4f888d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "glam", "lilt", @@ -1399,14 +1401,14 @@ dependencies = [ "num-traits", "rustc-hash 2.1.1", "smol_str", - "thiserror 2.0.17", + "thiserror 2.0.18", "web-time", ] [[package]] name = "iced_debug" -version = "0.14.0-dev" -source = "git+https://github.com/Lowband21/iced-ferrex.git#106ad05e21e624def9cde43aab3e5f40d3a95fd2" +version = "0.14.0" +source = "git+https://github.com/Lowband21/iced-ferrex.git#577abb7fa132ecd160adb5c8dfaf5c187b4f888d" dependencies = [ "iced_core", "iced_futures", @@ -1415,8 +1417,8 @@ dependencies = [ [[package]] name = "iced_futures" -version = "0.14.0-dev" -source = "git+https://github.com/Lowband21/iced-ferrex.git#106ad05e21e624def9cde43aab3e5f40d3a95fd2" +version = "0.14.0" +source = "git+https://github.com/Lowband21/iced-ferrex.git#577abb7fa132ecd160adb5c8dfaf5c187b4f888d" dependencies = [ "futures", "iced_core", @@ -1428,10 +1430,10 @@ dependencies = [ [[package]] name = "iced_graphics" -version = "0.14.0-dev" -source = "git+https://github.com/Lowband21/iced-ferrex.git#106ad05e21e624def9cde43aab3e5f40d3a95fd2" +version = "0.14.0" +source = "git+https://github.com/Lowband21/iced-ferrex.git#577abb7fa132ecd160adb5c8dfaf5c187b4f888d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytemuck", "cosmic-text", "half", @@ -1442,14 +1444,14 @@ dependencies = [ "log", "raw-window-handle", "rustc-hash 2.1.1", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-segmentation", ] [[package]] name = "iced_program" -version = "0.14.0-dev" -source = "git+https://github.com/Lowband21/iced-ferrex.git#106ad05e21e624def9cde43aab3e5f40d3a95fd2" +version = "0.14.0" +source = "git+https://github.com/Lowband21/iced-ferrex.git#577abb7fa132ecd160adb5c8dfaf5c187b4f888d" dependencies = [ "iced_graphics", "iced_runtime", @@ -1457,32 +1459,32 @@ dependencies = [ [[package]] name = "iced_renderer" -version = "0.14.0-dev" -source = "git+https://github.com/Lowband21/iced-ferrex.git#106ad05e21e624def9cde43aab3e5f40d3a95fd2" +version = "0.14.0" +source = "git+https://github.com/Lowband21/iced-ferrex.git#577abb7fa132ecd160adb5c8dfaf5c187b4f888d" dependencies = [ "iced_graphics", "iced_tiny_skia", "iced_wgpu", "log", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "iced_runtime" -version = "0.14.0-dev" -source = "git+https://github.com/Lowband21/iced-ferrex.git#106ad05e21e624def9cde43aab3e5f40d3a95fd2" +version = "0.14.0" +source = "git+https://github.com/Lowband21/iced-ferrex.git#577abb7fa132ecd160adb5c8dfaf5c187b4f888d" dependencies = [ "bytes", "iced_core", "iced_futures", "raw-window-handle", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "iced_tiny_skia" -version = "0.14.0-dev" -source = "git+https://github.com/Lowband21/iced-ferrex.git#106ad05e21e624def9cde43aab3e5f40d3a95fd2" +version = "0.14.0" +source = "git+https://github.com/Lowband21/iced-ferrex.git#577abb7fa132ecd160adb5c8dfaf5c187b4f888d" dependencies = [ "bytemuck", "cosmic-text", @@ -1497,10 +1499,10 @@ dependencies = [ [[package]] name = "iced_wgpu" -version = "0.14.0-dev" -source = "git+https://github.com/Lowband21/iced-ferrex.git#106ad05e21e624def9cde43aab3e5f40d3a95fd2" +version = "0.14.0" +source = "git+https://github.com/Lowband21/iced-ferrex.git#577abb7fa132ecd160adb5c8dfaf5c187b4f888d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytemuck", "cryoglyph", "futures", @@ -1510,33 +1512,33 @@ dependencies = [ "iced_graphics", "log", "rustc-hash 2.1.1", - "thiserror 2.0.17", + "thiserror 2.0.18", "wgpu", ] [[package]] name = "iced_widget" -version = "0.14.0-dev" -source = "git+https://github.com/Lowband21/iced-ferrex.git#106ad05e21e624def9cde43aab3e5f40d3a95fd2" +version = "0.14.2" +source = "git+https://github.com/Lowband21/iced-ferrex.git#577abb7fa132ecd160adb5c8dfaf5c187b4f888d" dependencies = [ "iced_renderer", "log", "num-traits", "rustc-hash 2.1.1", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-segmentation", ] [[package]] name = "iced_winit" -version = "0.14.0-dev" -source = "git+https://github.com/Lowband21/iced-ferrex.git#106ad05e21e624def9cde43aab3e5f40d3a95fd2" +version = "0.14.0" +source = "git+https://github.com/Lowband21/iced-ferrex.git#577abb7fa132ecd160adb5c8dfaf5c187b4f888d" dependencies = [ "iced_debug", "iced_program", "log", "rustc-hash 2.1.1", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "wasm-bindgen-futures", "web-sys", @@ -1592,9 +1594,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1606,9 +1608,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1625,6 +1627,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -1648,9 +1656,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -1666,8 +1674,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.0", - "zune-jpeg 0.5.5", + "zune-core", + "zune-jpeg", ] [[package]] @@ -1688,12 +1696,14 @@ checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1716,27 +1726,69 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-macros", + "jni-sys 0.4.1", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.18", "walkdir", - "windows-sys 0.45.0", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] [[package]] name = "jobserver" @@ -1744,15 +1796,15 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -1803,6 +1855,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lebe" version = "0.5.3" @@ -1811,15 +1869,15 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.178" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libfuzzer-sys" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" dependencies = [ "arbitrary", "cc", @@ -1837,19 +1895,20 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", - "redox_syscall 0.5.18", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -1875,9 +1934,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -1917,9 +1976,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.16.2" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" [[package]] name = "malloc_buf" @@ -1942,15 +2001,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" dependencies = [ "libc", ] @@ -1961,7 +2020,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block", "core-graphics-types 0.2.0", "foreign-types", @@ -1982,9 +2041,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.10" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80986bbbcf925ebd3be54c26613d861255284584501595cf418320c078945608" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -2010,7 +2069,7 @@ checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "codespan-reporting", @@ -2024,7 +2083,7 @@ dependencies = [ "once_cell", "rustc-hash 1.1.0", "spirv", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-ident", ] @@ -2034,8 +2093,8 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.10.0", - "jni-sys", + "bitflags 2.11.0", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", @@ -2055,7 +2114,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -2130,21 +2189,11 @@ dependencies = [ "libm", ] -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -2152,9 +2201,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2187,20 +2236,29 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + [[package]] name = "objc2-app-kit" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "libc", - "objc2", + "objc2 0.5.2", "objc2-core-data", "objc2-core-image", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", ] [[package]] @@ -2209,11 +2267,11 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", - "objc2", + "objc2 0.5.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -2223,8 +2281,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -2233,10 +2291,34 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", ] [[package]] @@ -2246,8 +2328,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", "objc2-metal", ] @@ -2258,9 +2340,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ "block2", - "objc2", + "objc2 0.5.2", "objc2-contacts", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -2275,11 +2357,33 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "dispatch", "libc", - "objc2", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", ] [[package]] @@ -2289,9 +2393,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2", - "objc2", + "objc2 0.5.2", "objc2-app-kit", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -2300,10 +2404,10 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -2312,21 +2416,33 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", "objc2-metal", ] +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-symbols" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -2335,16 +2451,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", - "objc2", + "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", "objc2-core-image", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", @@ -2357,8 +2473,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -2367,42 +2483,43 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", - "objc2", + "objc2 0.5.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "option-operations" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b31ce827892359f23d3cd1cc4c75a6c241772bbd2db17a92dcf27cbefdf52689" +checksum = "aca39cf52b03268400c16eeb9b56382ea3c3353409309b63f5c8f0b1faf42754" dependencies = [ - "pastey 0.1.1", + "pastey 0.2.1", ] [[package]] name = "orbclient" -version = "0.3.49" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "247ad146e19b9437f8604c21f8652423595cf710ad108af40e77d3ae6e96b827" +checksum = "59aed3b33578edcfa1bc96a321d590d31832b6ad55a26f0313362ce687e9abd6" dependencies = [ + "libc", "libredox", ] [[package]] name = "ordered-float" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +checksum = "0218004a4aae742209bee9c3cef05672f6b2708be36a50add8eb613b1f2a4008" dependencies = [ "num-traits", ] @@ -2453,9 +2570,9 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pastey" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d6c094ee800037dff99e02cab0eaf3142826586742a270ab3d7a62656bd27a" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" [[package]] name = "percent-encoding" @@ -2465,18 +2582,18 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", @@ -2485,9 +2602,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -2501,13 +2618,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", @@ -2524,21 +2647,21 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.2", ] [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -2567,20 +2690,30 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -2606,12 +2739,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.26" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3502d6155304a4173a5f2c34b52b7ed0dd085890326cb50fd625fdf39e86b3b" -dependencies = [ - "num-traits", -] +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" [[package]] name = "qoi" @@ -2630,18 +2760,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2652,6 +2782,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.9.2" @@ -2674,24 +2810,24 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] name = "range-alloc" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" [[package]] name = "rangemap" -version = "1.7.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbbbbea733ec66275512d0b9694f34102e7d5406fdbe2ad8d21b28dce92887c" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" [[package]] name = "rav1e" @@ -2723,16 +2859,16 @@ dependencies = [ "rand", "rand_chacha", "simd_helpers", - "thiserror 2.0.17", + "thiserror 2.0.18", "v_frame", "wasm-bindgen", ] [[package]] name = "ravif" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" dependencies = [ "avif-serialize", "imgref", @@ -2795,7 +2931,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", ] [[package]] @@ -2806,9 +2951,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "rgb" -version = "0.8.52" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" [[package]] name = "roxmltree" @@ -2828,13 +2973,22 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2843,14 +2997,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2896,9 +3050,15 @@ dependencies = [ [[package]] name = "self_cell" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" @@ -2930,11 +3090,24 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -2947,9 +3120,19 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] [[package]] name = "simd_helpers" @@ -2960,6 +3143,12 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "skrifa" version = "0.37.0" @@ -2972,15 +3161,15 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slotmap" -version = "1.0.7" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" dependencies = [ "version_check", ] @@ -2997,7 +3186,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "calloop 0.13.0", "calloop-wayland-source 0.3.0", "cursor-icon", @@ -3022,15 +3211,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.10.0", - "calloop 0.14.3", + "bitflags 2.11.0", + "calloop 0.14.4", "calloop-wayland-source 0.4.1", "cursor-icon", "libc", "log", "memmap2", - "rustix 1.1.2", - "thiserror 2.0.17", + "rustix 1.1.4", + "thiserror 2.0.18", "wayland-backend", "wayland-client", "wayland-csd-frame", @@ -3065,30 +3254,30 @@ dependencies = [ [[package]] name = "softbuffer" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", - "cfg_aliases", - "core-graphics 0.24.0", "fastrand", - "foreign-types", "js-sys", - "log", "memmap2", - "objc2", - "objc2-foundation", - "objc2-quartz-core", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "raw-window-handle", "redox_syscall 0.5.18", - "rustix 0.38.44", + "rustix 1.1.4", + "tracing", "wasm-bindgen", "wayland-backend", "wayland-client", "wayland-sys", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3097,7 +3286,7 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -3136,7 +3325,7 @@ name = "subwave_core" version = "0.1.0" dependencies = [ "gstreamer", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", ] @@ -3158,7 +3347,9 @@ dependencies = [ name = "subwave_wayland" version = "0.1.0" dependencies = [ + "ab_glyph", "gstreamer", + "gstreamer-app", "gstreamer-video", "gstreamer-video-sys", "iced", @@ -3168,11 +3359,12 @@ dependencies = [ "log", "once_cell", "parking_lot", + "quick-xml", "raw-window-handle", "rustc-hash 2.1.1", "subwave_core", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "wayland-backend", "wayland-client", @@ -3198,9 +3390,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3248,14 +3440,14 @@ checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3279,11 +3471,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -3299,9 +3491,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -3310,16 +3502,16 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", "half", "quick-error", "weezl", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] @@ -3359,9 +3551,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3374,60 +3566,69 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "toml" -version = "0.9.8" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.0+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -3448,9 +3649,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -3472,9 +3673,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-linebreak" @@ -3484,15 +3685,15 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-script" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb421b350c9aff471779e262955939f565ec18b86c15364e6bdf0d662ca7c1f" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -3500,11 +3701,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -3553,18 +3760,27 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -3575,11 +3791,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -3588,9 +3805,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3598,9 +3815,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -3611,13 +3828,47 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "wasmtimer" version = "0.4.3" @@ -3634,13 +3885,13 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.11" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.2", + "rustix 1.1.4", "scoped-tls", "smallvec", "wayland-sys", @@ -3648,12 +3899,12 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.11" +version = "0.31.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" dependencies = [ - "bitflags 2.10.0", - "rustix 1.1.2", + "bitflags 2.11.0", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -3664,29 +3915,29 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cursor-icon", "wayland-backend", ] [[package]] name = "wayland-cursor" -version = "0.31.11" +version = "0.31.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" +checksum = "4b3298683470fbdc6ca40151dfc48c8f2fd4c41a26e13042f801f85002384091" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.4", "wayland-client", "xcursor", ] [[package]] name = "wayland-protocols" -version = "0.32.9" +version = "0.32.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -3698,7 +3949,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -3707,11 +3958,11 @@ dependencies = [ [[package]] name = "wayland-protocols-misc" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dfe33d551eb8bffd03ff067a8b44bb963919157841a99957151299a6307d19c" +checksum = "429b99200febaf95d4f4e46deff6fe4382bcff3280ee16a41cf887b3c3364984" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -3720,11 +3971,11 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" +checksum = "d392fc283a87774afc9beefcd6f931582bb97fe0e6ced0b306a62cb1d026527c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -3733,11 +3984,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -3746,9 +3997,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.7" +version = "0.31.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" dependencies = [ "proc-macro2", "quick-xml", @@ -3757,9 +4008,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.7" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" dependencies = [ "dlib", "log", @@ -3769,9 +4020,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -3800,7 +4051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ "arrayvec", - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "document-features", @@ -3831,7 +4082,7 @@ dependencies = [ "arrayvec", "bit-set", "bit-vec", - "bitflags 2.10.0", + "bitflags 2.11.0", "bytemuck", "cfg_aliases", "document-features", @@ -3846,7 +4097,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", @@ -3891,7 +4142,7 @@ dependencies = [ "arrayvec", "ash", "bit-set", - "bitflags 2.10.0", + "bitflags 2.11.0", "block", "bytemuck", "cfg-if", @@ -3922,7 +4173,7 @@ dependencies = [ "raw-window-handle", "renderdoc-sys", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "wasm-bindgen", "web-sys", "wgpu-types", @@ -3936,11 +4187,11 @@ version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytemuck", "js-sys", "log", - "thiserror 2.0.17", + "thiserror 2.0.18", "web-sys", ] @@ -3955,16 +4206,16 @@ dependencies = [ [[package]] name = "window_clipboard" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5793d0b08c9e6a1240fe9ab2bd8db277487bf92436fd1a6321861a90a1b0cb7e" +checksum = "d5654226305eaf2dde8853fb482861d28e5dcecbbd40cb88e8393d94bb80d733" dependencies = [ "clipboard-win", "clipboard_macos", "clipboard_wayland", "clipboard_x11", "raw-window-handle", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -3974,7 +4225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" dependencies = [ "windows-core", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -3987,7 +4238,7 @@ dependencies = [ "windows-interface", "windows-result", "windows-strings", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4024,7 +4275,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4034,16 +4285,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ "windows-result", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", + "windows-targets", ] [[package]] @@ -4052,7 +4294,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4061,7 +4303,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4073,67 +4315,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4146,48 +4355,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4196,29 +4381,30 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" -version = "0.30.8" -source = "git+https://github.com/iced-rs/winit.git?rev=05b8ff17a06562f0a10bb46e6eaacbe2a95cb5ed#05b8ff17a06562f0a10bb46e6eaacbe2a95cb5ed" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "bytemuck", "calloop 0.13.0", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", - "core-graphics 0.23.2", + "core-graphics", "cursor-icon", "dpi", "js-sys", "libc", "memmap2", "ndk", - "objc2", + "objc2 0.5.2", "objc2-app-kit", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-ui-kit", "orbclient", "percent-encoding", @@ -4247,18 +4433,106 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -4288,7 +4562,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", "x11rb-protocol", ] @@ -4310,7 +4584,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dlib", "log", "once_cell", @@ -4372,18 +4646,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", @@ -4445,16 +4719,16 @@ dependencies = [ ] [[package]] -name = "zune-core" -version = "0.4.12" +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zune-core" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-inflate" @@ -4467,18 +4741,9 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.5" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fb7703e32e9a07fb3f757360338b3a567a5054f21b5f52a666752e333d58e" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core 0.5.0", + "zune-core", ] diff --git a/subwave_appsink/src/render_pipeline.rs b/subwave_appsink/src/render_pipeline.rs index ee9a31c..4d78313 100644 --- a/subwave_appsink/src/render_pipeline.rs +++ b/subwave_appsink/src/render_pipeline.rs @@ -47,11 +47,7 @@ pub(crate) struct VideoRenderPipeline { } impl Pipeline for VideoRenderPipeline { - fn new( - device: &wgpu::Device, - _queue: &wgpu::Queue, - format: wgpu::TextureFormat, - ) -> Self { + fn new(device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self { Self::new(device, format) } } diff --git a/subwave_core/src/http.rs b/subwave_core/src/http.rs index 83a9347..225232e 100644 --- a/subwave_core/src/http.rs +++ b/subwave_core/src/http.rs @@ -13,7 +13,7 @@ pub fn build_http_headers_context, U: AsRef>( { let s = ctx.get_mut().unwrap().structure_mut(); for (k, v) in headers.iter() { - s.set(k.as_ref(), &v.as_ref()); + s.set(k.as_ref(), v.as_ref()); } } Some(ctx) diff --git a/subwave_unified/src/video.rs b/subwave_unified/src/video.rs index 96179a8..91553da 100644 --- a/subwave_unified/src/video.rs +++ b/subwave_unified/src/video.rs @@ -213,7 +213,9 @@ impl SubwaveVideo { /// Open media with additional options such as start position and headers. pub fn open(uri: &url::Url, options: OpenOptions) -> Result { let backend = Self::select_backend(options.cfg); - let start = options.start_seconds.filter(|s| s.is_finite() && *s >= 0.0); + // Treat <= 0 as "no explicit start" to avoid an unnecessary startup seek-to-zero, + // which can trigger early pipeline reconfigure churn on some streams. + let start = options.start_seconds.filter(|s| s.is_finite() && *s > 0.0); match backend { BackendPreference::ForceAppsink => { let video = if let Some(s) = start { diff --git a/subwave_wayland/Cargo.toml b/subwave_wayland/Cargo.toml index 8ca052c..905fbe5 100644 --- a/subwave_wayland/Cargo.toml +++ b/subwave_wayland/Cargo.toml @@ -15,6 +15,8 @@ parking_lot = "0.12" once_cell = "^1.19" libc = "0.2.175" tempfile = "3.21.0" +ab_glyph = "0.2" +quick-xml = { version = "0.39.2", default-features = false, features = ["escape-html"] } [target.'cfg(target_os = "linux")'.dependencies] # Iced dependencies @@ -24,12 +26,13 @@ iced_winit.workspace = true # Wayland client-side dependencies wayland-client = "0.31" -wayland-protocols = { version = "0.32.9", features = ["client", "unstable"] } +wayland-protocols = { version = "0.32.9", features = ["client", "unstable", "staging"] } wayland-backend = "0.3" raw-window-handle = "0.6" # GStreamer dependencies gstreamer.workspace = true +gstreamer-app.workspace = true gstreamer-video.workspace = true gstreamer-video-sys.workspace = true diff --git a/subwave_wayland/src/color_management.rs b/subwave_wayland/src/color_management.rs new file mode 100644 index 0000000..b3223ed --- /dev/null +++ b/subwave_wayland/src/color_management.rs @@ -0,0 +1,458 @@ +//! Wayland `wp-color-management-v1` integration for per-surface color tagging. +//! +//! When the compositor advertises `wp_color_manager_v1`, this module can tag the +//! **video surface** with its HDR image description (BT.2020 + PQ) so the +//! compositor knows to tone-map correctly. The **subtitle surface** is left +//! **untagged** — per the protocol spec, untagged surfaces are treated as sRGB +//! by the compositor, which is exactly what we want for ARGB32 subtitle bitmaps. +//! +//! This eliminates the color-shift flicker that occurs when an SDR SHM subtitle +//! overlay is composited over an HDR DMABuf video plane without the compositor +//! knowing each surface's color space. + +use wayland_client::{Connection, Dispatch, QueueHandle}; +use wayland_protocols::wp::color_management::v1::client::{ + wp_color_management_surface_v1::WpColorManagementSurfaceV1, + wp_color_manager_v1::{self, WpColorManagerV1}, + wp_image_description_creator_params_v1::WpImageDescriptionCreatorParamsV1, + wp_image_description_v1::{self, WpImageDescriptionV1}, +}; + +use crate::Result; + +/// HDR metadata extracted from GStreamer caps / tags. +#[derive(Debug, Clone)] +pub struct HdrMetadata { + /// Mastering display colour volume primaries (CIE 1931 xy × 50000). + /// Order: Rx, Ry, Gx, Gy, Bx, By, Wx, Wy + /// e.g. from GStreamer: "34000:16000:13250:34500:7500:3000:15635:16450" + pub mastering_primaries: Option<[u32; 8]>, + /// Mastering display min luminance (× 10000) and max luminance (cd/m²). + /// e.g. from "10000000:50" → min=50 (0.005 cd/m²), max=10000000 (1000 cd/m²) + pub mastering_luminance_min: Option, + pub mastering_luminance_max: Option, + /// MaxCLL (cd/m²) from content-light-level first field + pub max_cll: Option, + /// MaxFALL (cd/m²) from content-light-level second field + pub max_fall: Option, +} + +impl HdrMetadata { + /// Parse GStreamer's `mastering-display-info` string. + /// + /// Format: `"Rx:Ry:Gx:Gy:Bx:By:Wx:Wy:MaxLum:MinLum"` + /// Values are CIE 1931 xy coordinates × 50000 and luminances in 1/10000 cd/m². + pub fn parse_mastering_display(s: &str) -> Option<([u32; 8], u32, u32)> { + let parts: Vec = s.split(':').filter_map(|p| p.parse().ok()).collect(); + if parts.len() >= 10 { + let primaries = [ + parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6], parts[7], + ]; + // GStreamer gives max luminance first, then min + let max_lum = parts[8]; + let min_lum = parts[9]; + Some((primaries, max_lum, min_lum)) + } else { + None + } + } + + /// Parse GStreamer's `content-light-level` string. + /// + /// Format: `"MaxCLL:MaxFALL"` in cd/m². + pub fn parse_content_light_level(s: &str) -> Option<(u32, u32)> { + let parts: Vec = s.split(':').filter_map(|p| p.parse().ok()).collect(); + if parts.len() >= 2 { + Some((parts[0], parts[1])) + } else { + None + } + } + + /// Detect whether a GStreamer colorimetry string indicates HDR (PQ/HLG). + /// + /// GStreamer colorimetry format: `"range:matrix:transfer:primaries"` + /// where transfer=14 is SMPTE ST 2084 (PQ), transfer=15 is ARIB STD-B67 (HLG), + /// and primaries=7 is BT.2020. + pub fn is_hdr_colorimetry(colorimetry: &str) -> bool { + let parts: Vec<&str> = colorimetry.split(':').collect(); + if parts.len() >= 4 { + let transfer = parts[2]; + // 14 = SMPTE ST 2084 (PQ), 15 = ARIB STD-B67 (HLG) + transfer == "14" || transfer == "15" + } else { + false + } + } + + /// Returns true if the pixel format can actually carry HDR data. + /// 8-bit formats like BGRx/BGRA/NV12 cannot — they indicate that + /// vapostproc already tone-mapped the content to SDR. + pub fn is_hdr_capable_format(format: &str) -> bool { + // Formats that can carry >8-bit or HDR data + matches!( + format, + "P010_10LE" + | "P010_10BE" + | "P012_LE" + | "P012_BE" + | "P016_LE" + | "P016_BE" + | "Y410" + | "Y412_LE" + | "Y412_BE" + | "Y210" + | "Y212_LE" + | "Y212_BE" + | "VUYA" + | "BGR10A2_LE" + | "RGB10A2_LE" + | "DMA_DRM" // DMABuf — format negotiated separately + ) + } +} + +/// Tracks everything we need to set color descriptions on surfaces. +pub struct ColorManager { + pub(crate) manager: WpColorManagerV1, + /// HDR image description (for video surface) — created on demand + hdr_desc: Option, + /// Color management surface wrapper for the video surface + video_cm_surface: Option, + /// Whether we have successfully tagged the video surface + video_tagged: bool, + /// Currently applied metadata (so we can detect changes) + applied_colorimetry: Option, + /// Compositor-advertised optional features + pub(crate) supports_set_luminances: bool, + pub(crate) supports_set_mastering_primaries: bool, +} + +/// State for tracking image-description readiness via events. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DescriptionRole { + HdrPq, +} + +impl ColorManager { + /// Try to bind `wp_color_manager_v1` from the registry globals. + /// + /// Returns `None` if the compositor does not advertise the protocol. + pub(crate) fn bind_if_available( + globals: &[(u32, String, u32)], + registry: &wayland_client::protocol::wl_registry::WlRegistry, + qh: &QueueHandle, + ) -> Option { + let global = globals + .iter() + .find(|(_, iface, _)| iface == "wp_color_manager_v1")?; + + let version = global.2.min(2); // protocol is at version 2 + let manager: WpColorManagerV1 = registry.bind(global.0, version, qh, ()); + log::info!( + "[color-mgmt] Bound wp_color_manager_v1 v{version} (global name={})", + global.0 + ); + + Some(Self { + manager, + hdr_desc: None, + video_cm_surface: None, + video_tagged: false, + applied_colorimetry: None, + supports_set_luminances: false, + supports_set_mastering_primaries: false, + }) + } + + /// Create the color-management surface wrapper (needed before we can tag). + /// Does NOT set any image description yet — the surface remains in its + /// compositor-default state (sRGB) until `tag_video_hdr` is called. + pub(crate) fn wrap_video_surface( + &mut self, + video_surface: &wayland_client::protocol::wl_surface::WlSurface, + qh: &QueueHandle, + ) { + if self.video_cm_surface.is_none() { + let cm_surface: WpColorManagementSurfaceV1 = + self.manager.get_surface(video_surface, qh, ()); + self.video_cm_surface = Some(cm_surface); + log::info!("[color-mgmt] Created color management surface wrapper for video"); + } + // NOTE: subtitle surface is deliberately NOT wrapped. + // Per the protocol: "By default, a surface does not have an associated + // image description… Compositors should handle such surfaces as sRGB" + // This is exactly what we want for ARGB32 subtitle overlays. + } + + /// Tag the video surface with a BT.2020 + PQ HDR image description. + /// + /// Call this when the video caps indicate HDR content. The description + /// is created with the provided metadata (or sensible defaults) and applied + /// to the video surface. The subtitle surface is left untagged (sRGB). + /// + /// `colorimetry` is the GStreamer colorimetry string for change detection. + pub(crate) fn tag_video_hdr( + &mut self, + colorimetry: &str, + metadata: Option<&HdrMetadata>, + video_surface: &wayland_client::protocol::wl_surface::WlSurface, + qh: &QueueHandle, + event_queue: &mut wayland_client::EventQueue, + ) -> Result<()> { + // Skip if we already applied the same colorimetry + if self.applied_colorimetry.as_deref() == Some(colorimetry) { + return Ok(()); + } + + // Ensure we have a CM surface wrapper + self.wrap_video_surface(video_surface, qh); + + let cm_surface = self + .video_cm_surface + .as_ref() + .ok_or_else(|| crate::Error::Wayland("No color management surface".into()))?; + + // Destroy old description if any + if let Some(old) = self.hdr_desc.take() { + old.destroy(); + } + + // Create new parametric HDR description + let creator: WpImageDescriptionCreatorParamsV1 = + self.manager.create_parametric_creator(qh, ()); + + // Primaries: BT.2020 + creator.set_primaries_named(wp_color_manager_v1::Primaries::Bt2020); + + // Transfer function: ST 2084 (PQ) + creator.set_tf_named(wp_color_manager_v1::TransferFunction::St2084Pq); + + // Luminances: reference white, min, max in cd/m² + // set_luminances(min_lum, max_lum, reference_lum) + // min_lum: minimum luminance (cd/m²) × 10000 + // max_lum: maximum luminance (cd/m²) unscaled + // reference_lum: reference white luminance (cd/m²) unscaled + // + // With ST 2084 PQ the protocol says max_lum is ignored and taken as + // min_lum + 10000, but we still set it for correctness. + // + // GStreamer mastering-display-info format: + // "Rx:Ry:Gx:Gy:Bx:By:Wx:Wy:MaxLum:MinLum" + // MaxLum and MinLum are in units of 1/10000 cd/m² + + // ── Optional features (only if compositor advertises support) ── + // Calling these without compositor support raises protocol error + // `unsupported_feature`, which can corrupt the description or + // disconnect us entirely. + + if self.supports_set_luminances { + if let Some(meta) = metadata { + if let (Some(max_raw), Some(min_raw)) = + (meta.mastering_luminance_max, meta.mastering_luminance_min) + { + let min_lum = min_raw; // already × 10000 + let max_lum = (max_raw / 10000).max(1); // convert to cd/m² + let reference_lum = 203_u32; // PQ reference white + creator.set_luminances(min_lum, max_lum, reference_lum); + log::info!( + "[color-mgmt] Mastering luminance: min={min_raw}/10000 cd/m², max={max_lum} cd/m²" + ); + } else { + creator.set_luminances(50, 1000, 203); + } + } else { + creator.set_luminances(50, 1000, 203); + } + } else { + log::debug!("[color-mgmt] Skipping set_luminances (not advertised by compositor)"); + } + + if self.supports_set_mastering_primaries { + if let Some(meta) = metadata { + if let Some(prims) = meta.mastering_primaries { + let scale = |v: u32| -> i32 { (v * 20) as i32 }; + creator.set_mastering_display_primaries( + scale(prims[0]), + scale(prims[1]), + scale(prims[2]), + scale(prims[3]), + scale(prims[4]), + scale(prims[5]), + scale(prims[6]), + scale(prims[7]), + ); + log::info!("[color-mgmt] Set mastering display primaries from stream metadata"); + } + + if let Some(max_cll) = meta.max_cll { + creator.set_max_cll(max_cll); + } + if let Some(max_fall) = meta.max_fall { + creator.set_max_fall(max_fall); + } + } + } else { + log::debug!( + "[color-mgmt] Skipping mastering primaries/CLL/FALL (not advertised by compositor)" + ); + } + + let desc = creator.create(qh, DescriptionRole::HdrPq); + + // Roundtrip to receive the ready/failed event for the description + let mut state = super::subsurface_manager::State::new(); + event_queue + .roundtrip(&mut state) + .map_err(|e| crate::Error::Wayland(format!("Roundtrip for HDR desc: {}", e)))?; + + // Apply to the video surface + cm_surface.set_image_description(&desc, wp_color_manager_v1::RenderIntent::Perceptual); + video_surface.commit(); + + self.hdr_desc = Some(desc); + self.video_tagged = true; + self.applied_colorimetry = Some(colorimetry.to_string()); + + log::info!("[color-mgmt] Tagged video surface with BT.2020+PQ (colorimetry={colorimetry})"); + + Ok(()) + } + + /// Remove the HDR tag from the video surface, returning it to compositor + /// default (sRGB). Call when switching to SDR content. + pub(crate) fn untag_video( + &mut self, + video_surface: &wayland_client::protocol::wl_surface::WlSurface, + ) { + if !self.video_tagged { + return; + } + if let Some(ref cm) = self.video_cm_surface { + cm.unset_image_description(); + video_surface.commit(); + log::info!("[color-mgmt] Removed HDR tag from video surface (back to sRGB default)"); + } + if let Some(desc) = self.hdr_desc.take() { + desc.destroy(); + } + self.video_tagged = false; + self.applied_colorimetry = None; + } + + /// Returns true if the video surface is currently tagged with an HDR description. + pub fn is_video_tagged_hdr(&self) -> bool { + self.video_tagged + } + + /// Clean up color management resources. + pub fn destroy(&mut self) { + if let Some(cm) = self.video_cm_surface.take() { + cm.destroy(); + } + if let Some(desc) = self.hdr_desc.take() { + desc.destroy(); + } + self.manager.destroy(); + self.video_tagged = false; + self.applied_colorimetry = None; + log::debug!("[color-mgmt] Destroyed color management resources"); + } +} + +// ── Dispatch implementations ────────────────────────────────────────────── + +impl Dispatch for super::subsurface_manager::State { + fn event( + state: &mut Self, + _proxy: &WpColorManagerV1, + event: wp_color_manager_v1::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + match event { + wp_color_manager_v1::Event::SupportedIntent { render_intent } => { + log::debug!("[color-mgmt] Compositor supports render intent: {render_intent:?}"); + } + wp_color_manager_v1::Event::SupportedFeature { feature } => { + log::info!("[color-mgmt] Compositor supports feature: {feature:?}"); + // Track features we care about + use wayland_client::WEnum; + match feature { + WEnum::Value(wp_color_manager_v1::Feature::SetLuminances) => { + state.cm_supports_set_luminances = true; + } + WEnum::Value(wp_color_manager_v1::Feature::SetMasteringDisplayPrimaries) => { + state.cm_supports_set_mastering_primaries = true; + } + _ => {} + } + } + wp_color_manager_v1::Event::SupportedTfNamed { tf } => { + log::debug!("[color-mgmt] Compositor supports transfer function: {tf:?}"); + } + wp_color_manager_v1::Event::SupportedPrimariesNamed { primaries } => { + log::debug!("[color-mgmt] Compositor supports primaries: {primaries:?}"); + } + wp_color_manager_v1::Event::Done => { + log::info!( + "[color-mgmt] Compositor capabilities done (luminances={}, mastering_primaries={})", + state.cm_supports_set_luminances, + state.cm_supports_set_mastering_primaries, + ); + } + _ => {} + } + } +} + +impl Dispatch for super::subsurface_manager::State { + fn event( + _state: &mut Self, + _proxy: &WpColorManagementSurfaceV1, + _event: ::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + // wp_color_management_surface_v1 has no events in the current protocol version + } +} + +impl Dispatch for super::subsurface_manager::State { + fn event( + _state: &mut Self, + _proxy: &WpImageDescriptionCreatorParamsV1, + _event: ::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + // The params creator has no events (it is destroyed by the `create` request) + } +} + +impl Dispatch for super::subsurface_manager::State { + fn event( + _state: &mut Self, + _proxy: &WpImageDescriptionV1, + event: wp_image_description_v1::Event, + data: &DescriptionRole, + _conn: &Connection, + _qh: &QueueHandle, + ) { + match event { + wp_image_description_v1::Event::Failed { msg, cause } => { + log::error!( + "[color-mgmt] Image description {:?} FAILED: cause={cause:?} msg={msg}", + data + ); + } + _ => { + // Ready events (ready / ready2) — logged for diagnostics + log::info!("[color-mgmt] Image description {:?} event: {event:?}", data); + } + } + } +} diff --git a/subwave_wayland/src/gstplayflags.rs b/subwave_wayland/src/gstplayflags.rs index 8966639..eb9a5e5 100644 --- a/subwave_wayland/src/gstplayflags.rs +++ b/subwave_wayland/src/gstplayflags.rs @@ -140,15 +140,18 @@ pub mod gst_play_flags { } pub fn wayland_native() -> Self { - // Wayland path renders subtitles via subwave_overlay, so we keep - // playbin's text handling disabled to avoid autoplugging CPU paths. + // Subtitle rendering is handled entirely out-of-band via + // demuxer pad probes (PGS bitmap and text/x-raw). We omit + // TEXT so playbin3 never activates subtitleoverlay, which + // would interfere with HDR passthrough on the video chain. + // NATIVE_VIDEO is safe because subtitleoverlay is never + // involved. Self::VIDEO | Self::AUDIO - | Self::TEXT + | Self::NATIVE_VIDEO | Self::SOFT_VOLUME | Self::BUFFERING | Self::DEINTERLACE - | Self::NATIVE_VIDEO } pub fn network_no_subs() -> Self { diff --git a/subwave_wayland/src/internal.rs b/subwave_wayland/src/internal.rs index 41093cb..a1e407c 100644 --- a/subwave_wayland/src/internal.rs +++ b/subwave_wayland/src/internal.rs @@ -5,13 +5,19 @@ use std::{ }; use gstreamer::StreamCollection; +use parking_lot::Mutex as ParkMutex; use std::sync::mpsc; use subwave_core::{ types::PendingState, video::types::{AudioTrack, SubtitleTrack}, }; -use crate::{pipeline::SubsurfacePipeline, video::Cmd, WaylandSubsurfaceManager}; +use crate::{ + pipeline::SubsurfacePipeline, + subtitle_runtime::{ActiveSubtitleSelection, SubtitleProbeEvent, WaylandSubtitleScheduler}, + video::Cmd, + WaylandSubsurfaceManager, +}; // Internal encapsulates all state and is only accessed behind the RwLock pub(crate) struct Internal { @@ -42,6 +48,9 @@ pub(crate) struct Internal { // Command receiver for bus->UI updates pub(crate) cmd_rx: Option>, + // Startup readiness: first AsyncDone observed from pipeline + pub(crate) startup_async_done: bool, + // Track selection state pub(crate) stream_collection: Option, @@ -49,6 +58,10 @@ pub(crate) struct Internal { pub(crate) available_subtitles: Vec, pub(crate) current_subtitle_track: Option, pub(crate) subtitles_enabled: bool, + pub(crate) pgs_stream_ids: Vec, + pub(crate) active_subtitle_selection: Arc>, + pub(crate) subtitle_event_rx: Option>, + pub(crate) subtitle_scheduler: Option, // Audio track tracking pub(crate) available_audio_tracks: Vec, diff --git a/subwave_wayland/src/lib.rs b/subwave_wayland/src/lib.rs index 3002bd5..cb8046d 100644 --- a/subwave_wayland/src/lib.rs +++ b/subwave_wayland/src/lib.rs @@ -1,14 +1,23 @@ #[cfg(target_os = "linux")] +pub mod color_management; +#[cfg(target_os = "linux")] pub mod gstplayflags; #[cfg(target_os = "linux")] pub mod internal; #[cfg(target_os = "linux")] +pub mod pgs_decoder; +#[cfg(target_os = "linux")] mod pipeline; #[cfg(target_os = "linux")] mod position; #[cfg(target_os = "linux")] pub mod subsurface_manager; #[cfg(target_os = "linux")] +mod subtitle_runtime; +pub mod subtitle_scheduler; +#[cfg(target_os = "linux")] +pub mod text_renderer; +#[cfg(target_os = "linux")] mod video; #[cfg(target_os = "linux")] mod video_player; diff --git a/subwave_wayland/src/pgs_decoder.rs b/subwave_wayland/src/pgs_decoder.rs new file mode 100644 index 0000000..4e3ce65 --- /dev/null +++ b/subwave_wayland/src/pgs_decoder.rs @@ -0,0 +1,670 @@ +//! Streaming PGS (Presentation Graphic Stream) subtitle decoder. +//! +//! Decodes `subpicture/x-pgs` buffers received from GStreamer into ARGB +//! bitmaps suitable for the Wayland subtitle subsurface. +//! +//! PGS segments arrive individually as GStreamer buffers. Each buffer +//! contains one segment (no 2-byte PG sync header — GStreamer strips it). +//! A complete subtitle frame ("display set") is assembled from multiple +//! segments: PCS → WDS → PDS → ODS → END. + +use std::time::Duration; + +/// A fully decoded PGS subtitle image ready for display. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PgsFrame { + /// ARGB pixel data (premultiplied alpha, row-major). + pub argb: Vec, + /// Width of the image in pixels. + pub width: u32, + /// Height of the image in pixels. + pub height: u32, + /// X position on the video frame. + pub x: u16, + /// Y position on the video frame. + pub y: u16, +} + +/// Presentation action represented by a complete PGS display set. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PgsDisplaySet { + /// Present one or more decoded composition objects. + Show(Vec), + /// Clear the currently visible subtitle composition. + Clear, +} + +/// A decoded PGS display set with an optional raw PGS PTS timestamp. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TimedPgsDisplaySet { + pub action: PgsDisplaySet, + /// Raw PGS segment PTS converted from 90 kHz ticks to stream time. + pub pts: Option, +} + +/// Accumulates PGS segments and produces decoded display sets. +pub struct PgsDecoder { + palette: Vec<[u8; 4]>, // 256 ARGB entries + objects: Vec, + compositions: Vec, + seen_pcs: bool, + pcs_composition_state: u8, + pcs_object_count: u8, + display_set_pts: Option, + /// Video dimensions from PCS (used for coordinate scaling) + pub video_width: u16, + pub video_height: u16, +} + +struct OdsData { + id: u16, + width: u16, + height: u16, + rle: Vec, +} + +#[derive(Clone)] +struct CompositionObject { + object_id: u16, + x: u16, + y: u16, +} + +// PGS segment types (the type byte within the segment header) +const SEG_PCS: u8 = 0x16; // Presentation Composition Segment +const SEG_WDS: u8 = 0x17; // Window Definition Segment +const SEG_PDS: u8 = 0x14; // Palette Definition Segment +const SEG_ODS: u8 = 0x15; // Object Definition Segment +const SEG_END: u8 = 0x80; // End of Display Set + +fn pgs_timestamp_to_duration(timestamp_90khz: u32) -> Duration { + Duration::from_nanos((timestamp_90khz as u64).saturating_mul(1_000_000_000) / 90_000) +} + +impl Default for PgsDecoder { + fn default() -> Self { + Self::new() + } +} + +impl PgsDecoder { + pub fn new() -> Self { + Self { + palette: vec![[0, 0, 0, 0]; 256], + objects: Vec::new(), + compositions: Vec::new(), + seen_pcs: false, + pcs_composition_state: 0, + pcs_object_count: 0, + display_set_pts: None, + video_width: 1920, + video_height: 1080, + } + } + + /// Feed a raw PGS buffer from GStreamer. + /// + /// Matroska containers deliver PGS as complete display sets where all + /// segments are concatenated in a single buffer. Each segment has: + /// [1B type][2B size][...payload...] + /// + /// This method loops through all segments in the buffer and returns + /// decoded display-set actions when an END segment is encountered. Display + /// sets without a PCS are resource updates/preloads and do not emit actions. + pub fn feed(&mut self, data: &[u8]) -> Vec { + let mut results = Vec::new(); + let mut offset = 0usize; + + while offset < data.len() { + // Try to parse a segment at the current offset. + // PGS segments: [type:1][size:2][payload:size] + // Some muxers include the 2-byte "PG" sync (0x50 0x47) before + // each segment — skip it if present. + let mut segment_pts = None; + if offset + 2 <= data.len() && data[offset] == 0x50 && data[offset + 1] == 0x47 { + // Skip "PG" sync marker + offset += 2; + // After sync: [PTS:4][DTS:4][type:1][size:2][payload...] + if offset + 11 > data.len() { + break; + } + let pts = u32::from_be_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + segment_pts = Some(pgs_timestamp_to_duration(pts)); + // Skip PTS(4) + DTS(4) to get to type + offset += 8; + } + + if offset + 3 > data.len() { + break; + } + + let seg_type = data[offset]; + let seg_size = u16::from_be_bytes([data[offset + 1], data[offset + 2]]) as usize; + let payload_start = offset + 3; + let payload_end = payload_start + seg_size; + + if payload_end > data.len() { + log::debug!( + "[pgs] Segment truncated: type=0x{:02x} size={} but only {} bytes remain", + seg_type, + seg_size, + data.len() - payload_start + ); + break; + } + + let payload = &data[payload_start..payload_end]; + + if let Some(pts) = segment_pts { + if self.display_set_pts.is_none() || seg_type == SEG_PCS { + self.display_set_pts = Some(pts); + } + } + + match seg_type { + SEG_PCS => { + log::debug!("[pgs] PCS segment ({} bytes)", payload.len()); + self.parse_pcs(payload); + } + SEG_WDS => { + log::debug!("[pgs] WDS segment ({} bytes)", payload.len()); + } + SEG_PDS => { + log::debug!( + "[pgs] PDS segment ({} bytes, {} entries)", + payload.len(), + (payload.len().saturating_sub(2)) / 5 + ); + self.parse_pds(payload); + } + SEG_ODS => { + log::debug!("[pgs] ODS segment ({} bytes)", payload.len()); + self.parse_ods(payload); + } + SEG_END => { + let pts = self.display_set_pts; + log::info!( + "[pgs] END — display set complete ({} objects, {} compositions, pcs={}, state=0x{:02x}, pts={:?})", + self.objects.len(), + self.compositions.len(), + self.seen_pcs, + self.pcs_composition_state, + pts + ); + if let Some(action) = self.finish_display_set() { + results.push(TimedPgsDisplaySet { action, pts }); + } + } + _ => { + log::debug!( + "[pgs] Unknown segment type: 0x{:02x} at offset {}", + seg_type, + offset + ); + // Can't determine size — bail out of this buffer + break; + } + } + + offset = payload_end; + } + + results + } + + fn parse_pcs(&mut self, data: &[u8]) { + if data.len() < 11 { + return; + } + self.video_width = u16::from_be_bytes([data[0], data[1]]); + self.video_height = u16::from_be_bytes([data[2], data[3]]); + // data[4] = frame_rate + // data[5..7] = composition_number (u16) + let composition_state = data[7]; + // data[8] = palette_update_flag + // data[9] = palette_id + let num_objects = data[10]; + + self.seen_pcs = true; + self.pcs_composition_state = composition_state; + self.pcs_object_count = num_objects; + self.compositions.clear(); + + if composition_state == 0x00 && num_objects == 0 { + // Normal update with 0 objects = clear subtitle + return; + } + + let mut offset = 11; + for _ in 0..num_objects { + if offset + 8 > data.len() { + break; + } + let object_id = u16::from_be_bytes([data[offset], data[offset + 1]]); + // offset+2 = window_id + let _cropped = data[offset + 3]; + let x = u16::from_be_bytes([data[offset + 4], data[offset + 5]]); + let y = u16::from_be_bytes([data[offset + 6], data[offset + 7]]); + self.compositions + .push(CompositionObject { object_id, x, y }); + offset += 8; + // If cropped flag is set (0x40), skip 8 more bytes of crop data + if _cropped & 0x40 != 0 { + offset += 8; + } + } + } + + fn parse_pds(&mut self, data: &[u8]) { + if data.len() < 2 { + return; + } + // data[0] = palette_id, data[1] = palette_version + let entries = &data[2..]; + // Each entry: [id:1][Y:1][Cr:1][Cb:1][A:1] = 5 bytes + let mut i = 0; + while i + 5 <= entries.len() { + let idx = entries[i] as usize; + let y_val = entries[i + 1] as f32; + let cr = entries[i + 2] as f32; + let cb = entries[i + 3] as f32; + let a = entries[i + 4]; + + // YCbCr (BT.709) → RGB conversion + let r = (y_val + 1.402 * (cr - 128.0)).clamp(0.0, 255.0) as u8; + let g = + (y_val - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0)).clamp(0.0, 255.0) as u8; + let b = (y_val + 1.772 * (cb - 128.0)).clamp(0.0, 255.0) as u8; + + if idx < 256 { + // ARGB format (premultiplied for Wayland) + let pa = a as u16; + let pr = ((r as u16) * pa / 255) as u8; + let pg = ((g as u16) * pa / 255) as u8; + let pb = ((b as u16) * pa / 255) as u8; + self.palette[idx] = [a, pr, pg, pb]; + } + i += 5; + } + } + + fn parse_ods(&mut self, data: &[u8]) { + if data.len() < 7 { + return; + } + let object_id = u16::from_be_bytes([data[0], data[1]]); + // data[2] = object_version + let seq_flag = data[3]; + // data[4..7] = data_length (24-bit) + + let is_first = seq_flag & 0xC0 == 0xC0 || seq_flag & 0x80 != 0; + let is_last = seq_flag & 0xC0 == 0xC0 || seq_flag & 0x40 != 0; + + if is_first { + // First (or only) segment — contains width/height + if data.len() < 11 { + return; + } + let width = u16::from_be_bytes([data[7], data[8]]); + let height = u16::from_be_bytes([data[9], data[10]]); + let rle = data[11..].to_vec(); + + // Replace or insert + if let Some(obj) = self.objects.iter_mut().find(|o| o.id == object_id) { + obj.width = width; + obj.height = height; + obj.rle = rle; + } else { + self.objects.push(OdsData { + id: object_id, + width, + height, + rle, + }); + } + } else { + // Continuation segment — append RLE data + let _rle_data = &data[4..]; // skip object_id(2) + version(1) + seq_flag(1) + // Actually, continuation starts at offset 7 (after 3-byte data_length) + let rle_data = if data.len() > 7 { + &data[7..] + } else { + &data[4..] + }; + if let Some(obj) = self.objects.iter_mut().find(|o| o.id == object_id) { + obj.rle.extend_from_slice(rle_data); + } + } + + if is_last { + log::trace!( + "[pgs] ODS complete: id={} size={}", + object_id, + self.objects + .iter() + .find(|o| o.id == object_id) + .map(|o| o.rle.len()) + .unwrap_or(0) + ); + } + } + + fn finish_display_set(&mut self) -> Option { + if !self.seen_pcs { + // Some streams split palette/object resource updates into standalone + // display sets. They update decoder state for future compositions + // but must not clear the currently visible subtitle. + self.reset_display_set_state(); + return None; + } + + if self.pcs_object_count == 0 { + let is_normal_clear = self.pcs_composition_state == 0x00; + self.reset_display_set_state(); + return is_normal_clear.then_some(PgsDisplaySet::Clear); + } + + if self.compositions.is_empty() { + self.reset_display_set_state(); + return None; + } + + let mut frames = Vec::new(); + + for comp in &self.compositions { + let Some(obj) = self.objects.iter().find(|o| o.id == comp.object_id) else { + continue; + }; + + if obj.width == 0 || obj.height == 0 { + continue; + } + + match self.decode_rle(obj) { + Ok(argb) => { + frames.push(PgsFrame { + argb, + width: obj.width as u32, + height: obj.height as u32, + x: comp.x, + y: comp.y, + }); + } + Err(e) => { + log::warn!("[pgs] RLE decode failed: {e}"); + } + } + } + + // Don't clear objects — they may be referenced by future compositions + // (PGS allows reusing objects across display sets) + self.reset_display_set_state(); + + if frames.is_empty() { + None + } else { + Some(PgsDisplaySet::Show(frames)) + } + } + + fn reset_display_set_state(&mut self) { + self.seen_pcs = false; + self.pcs_composition_state = 0; + self.pcs_object_count = 0; + self.display_set_pts = None; + self.compositions.clear(); + } + + fn decode_rle(&self, obj: &OdsData) -> Result, String> { + let pixel_count = obj.width as usize * obj.height as usize; + let mut argb = vec![0u8; pixel_count * 4]; + let mut pos = 0usize; // position in output pixels + let mut i = 0usize; // position in RLE data + + let rle = &obj.rle; + + while i < rle.len() && pos < pixel_count { + let byte = rle[i]; + i += 1; + + if byte != 0 { + // Single pixel with palette index + self.write_pixel(&mut argb, pos, byte); + pos += 1; + } else { + // Run-length encoded sequence + if i >= rle.len() { + break; + } + let flag = rle[i]; + i += 1; + + if flag == 0 { + // End of line — advance to next row boundary + let row_w = obj.width as usize; + if row_w > 0 { + let remainder = pos % row_w; + if remainder != 0 { + pos += row_w - remainder; + } + } + } else if flag & 0xC0 == 0x00 { + // Short run of color 0: length = flag (6 bits) + let run = (flag & 0x3F) as usize; + for _ in 0..run.min(pixel_count - pos) { + self.write_pixel(&mut argb, pos, 0); + pos += 1; + } + } else if flag & 0xC0 == 0x40 { + // Long run of color 0: length = (flag & 0x3F) << 8 | next + if i >= rle.len() { + break; + } + let run = (((flag & 0x3F) as usize) << 8) | rle[i] as usize; + i += 1; + for _ in 0..run.min(pixel_count - pos) { + self.write_pixel(&mut argb, pos, 0); + pos += 1; + } + } else if flag & 0xC0 == 0x80 { + // Short run of color N: length = flag & 0x3F, color = next + if i >= rle.len() { + break; + } + let run = (flag & 0x3F) as usize; + let color = rle[i]; + i += 1; + for _ in 0..run.min(pixel_count - pos) { + self.write_pixel(&mut argb, pos, color); + pos += 1; + } + } else { + // 0xC0: Long run of color N + if i + 1 >= rle.len() { + break; + } + let run = (((flag & 0x3F) as usize) << 8) | rle[i] as usize; + let color = rle[i + 1]; + i += 2; + for _ in 0..run.min(pixel_count - pos) { + self.write_pixel(&mut argb, pos, color); + pos += 1; + } + } + } + } + + Ok(argb) + } + + #[inline] + fn write_pixel(&self, buf: &mut [u8], pos: usize, palette_idx: u8) { + let offset = pos * 4; + if offset + 4 <= buf.len() { + let [a, r, g, b] = self.palette[palette_idx as usize]; + // WL_SHM_FORMAT_ARGB8888: on little-endian, bytes are [B, G, R, A] + buf[offset] = b; + buf[offset + 1] = g; + buf[offset + 2] = r; + buf[offset + 3] = a; + } + } +} + +#[cfg(test)] +mod tests { + use super::{PgsDecoder, PgsDisplaySet, TimedPgsDisplaySet}; + use std::time::Duration; + + fn segment(segment_type: u8, payload: &[u8]) -> Vec { + let mut data = Vec::with_capacity(3 + payload.len()); + data.push(segment_type); + data.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + data.extend_from_slice(payload); + data + } + + fn end_segment() -> Vec { + segment(0x80, &[]) + } + + fn pcs_payload(num_objects: u8) -> Vec { + pcs_payload_with_state(0x00, num_objects) + } + + fn pcs_payload_with_state(composition_state: u8, num_objects: u8) -> Vec { + let mut payload = vec![ + 0x07, + 0x80, // width 1920 + 0x04, + 0x38, // height 1080 + 0x10, // frame rate marker + 0x00, + 0x01, // composition number + composition_state, + 0x00, // palette update flag + 0x00, // palette id + num_objects, + ]; + + if num_objects > 0 { + payload.extend_from_slice(&[ + 0x00, 0x01, // object id + 0x00, // window id + 0x00, // cropped flag + 0x00, 0x0a, // x + 0x00, 0x14, // y + ]); + } + + payload + } + + fn ods_payload() -> Vec { + vec![ + 0x00, 0x01, // object id + 0x00, // object version + 0xc0, // first and last ODS fragment + 0x00, 0x00, 0x05, // object data length + 0x00, 0x01, // width + 0x00, 0x01, // height + 0x01, // one pixel using palette index 1 + ] + } + + fn pds_payload() -> Vec { + vec![ + 0x00, // palette id + 0x00, // palette version + 0x01, // entry id + 0xeb, // Y + 0x80, // Cr + 0x80, // Cb + 0xff, // alpha + ] + } + + #[test] + fn object_only_display_sets_are_resource_updates_not_clears() { + let mut decoder = PgsDecoder::new(); + let mut data = segment(0x15, &ods_payload()); + data.extend_from_slice(&end_segment()); + + assert!(decoder.feed(&data).is_empty()); + } + + #[test] + fn zero_object_normal_pcs_display_sets_emit_clear() { + let mut decoder = PgsDecoder::new(); + let mut data = segment(0x16, &pcs_payload(0)); + data.extend_from_slice(&end_segment()); + + assert_eq!( + decoder.feed(&data), + vec![TimedPgsDisplaySet { + action: PgsDisplaySet::Clear, + pts: None, + }] + ); + } + + #[test] + fn zero_object_epoch_and_acquisition_pcs_display_sets_do_not_clear() { + for composition_state in [0x40, 0x80] { + let mut decoder = PgsDecoder::new(); + let mut data = segment(0x16, &pcs_payload_with_state(composition_state, 0)); + data.extend_from_slice(&end_segment()); + + assert!(decoder.feed(&data).is_empty()); + } + } + + #[test] + fn composition_display_sets_emit_show() { + let mut decoder = PgsDecoder::new(); + let mut data = segment(0x14, &pds_payload()); + data.extend_from_slice(&segment(0x15, &ods_payload())); + data.extend_from_slice(&segment(0x16, &pcs_payload(1))); + data.extend_from_slice(&end_segment()); + + let display_sets = decoder.feed(&data); + assert_eq!(display_sets.len(), 1); + match &display_sets[0].action { + PgsDisplaySet::Show(frames) => { + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].width, 1); + assert_eq!(frames[0].height, 1); + assert_eq!(frames[0].x, 10); + assert_eq!(frames[0].y, 20); + } + PgsDisplaySet::Clear => panic!("composition display set unexpectedly cleared"), + } + } + + #[test] + fn pgs_segment_headers_provide_display_set_pts() { + let pts_90khz = 180_000u32; + let payload = pcs_payload(0); + let mut data = Vec::new(); + data.extend_from_slice(b"PG"); + data.extend_from_slice(&pts_90khz.to_be_bytes()); + data.extend_from_slice(&0u32.to_be_bytes()); + data.push(0x16); + data.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + data.extend_from_slice(&payload); + data.extend_from_slice(&end_segment()); + + assert_eq!( + PgsDecoder::new().feed(&data), + vec![TimedPgsDisplaySet { + action: PgsDisplaySet::Clear, + pts: Some(Duration::from_secs(2)), + }] + ); + } +} diff --git a/subwave_wayland/src/pipeline.rs b/subwave_wayland/src/pipeline.rs index 42db348..40ccc0d 100644 --- a/subwave_wayland/src/pipeline.rs +++ b/subwave_wayland/src/pipeline.rs @@ -4,13 +4,119 @@ use gstreamer_video::{ prelude::{VideoOverlayExt, VideoOverlayExtManual}, VideoOverlay, }; -use std::sync::Arc; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::Duration; use crate::gstplayflags::gst_play_flags::GstPlayFlags; -use crate::{Error, Result, WaylandIntegration, WaylandSubsurfaceManager}; +use crate::{ + subtitle_runtime::{ + duration_from_clock_time, ActiveSubtitleSelection, SubtitleProbeEvent, + WaylandSubtitlePayload, + }, + Error, Result, WaylandIntegration, WaylandSubsurfaceManager, +}; use subwave_core::video::types::Position; +/// Build a `GstWaylandDisplayHandleContextType` context carrying `display`. +/// +/// `display_addr` is the raw `wl_display*` pointer cast to `usize` so it can be +/// captured by GStreamer sync handlers without making the closure `!Send`. +fn wayland_display_context(display_addr: usize) -> gst::Context { + const CTX_TYPE: &str = "GstWaylandDisplayHandleContextType"; + + let mut context = gst::Context::new(CTX_TYPE, true); + { + let context = context.get_mut().unwrap(); + let structure = context.structure_mut(); + unsafe { + use glib::translate::{ToGlibPtr, ToGlibPtrMut}; + use gstreamer::ffi as gst_ffi; + + let mut value = glib::Value::from_type(glib::Type::POINTER); + glib::gobject_ffi::g_value_set_pointer( + value.to_glib_none_mut().0, + display_addr as *mut std::ffi::c_void, + ); + + gst_ffi::gst_structure_set_value( + structure.as_ptr() as *mut gst_ffi::GstStructure, + c"display".as_ptr(), + value.to_glib_none().0, + ); + } + } + context +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SubtitlePadKind { + Pgs, + Text, +} + +struct PadSubtitleTiming { + segment: gst::FormattedSegment, +} + +impl PadSubtitleTiming { + fn new() -> Self { + Self { + segment: gst::FormattedSegment::::new(), + } + } + + fn update_segment(&mut self, segment: &gst::FormattedSegment) { + self.segment.set_flags(segment.flags()); + self.segment.set_rate(segment.rate()); + self.segment.set_applied_rate(segment.applied_rate()); + self.segment.set_base(segment.base()); + self.segment.set_offset(segment.offset()); + self.segment.set_start(segment.start()); + self.segment.set_stop(segment.stop()); + self.segment.set_time(segment.time()); + self.segment.set_position(segment.position()); + self.segment.set_duration(segment.duration()); + } + + fn running_time_for(&self, timestamp: gst::ClockTime) -> Option { + self.segment + .to_running_time(timestamp) + .map(duration_from_clock_time) + } +} + +fn pgs_display_set_event( + stream_id: String, + generation: u64, + start: Duration, + display_set: crate::pgs_decoder::PgsDisplaySet, + video_width: u16, + video_height: u16, +) -> crate::subtitle_scheduler::DecodedSubtitleEvent { + match display_set { + crate::pgs_decoder::PgsDisplaySet::Clear => { + crate::subtitle_scheduler::DecodedSubtitleEvent::clear(stream_id, generation, start) + } + crate::pgs_decoder::PgsDisplaySet::Show(frames) => { + // PGS display sets are stateful: a non-empty display set remains visible + // until a later display set replaces it or an empty composition clears it. + // The GstBuffer duration describes packet timing, not subtitle visibility. + crate::subtitle_scheduler::DecodedSubtitleEvent::show( + stream_id, + generation, + start, + None, + WaylandSubtitlePayload::Pgs { + frames, + video_width, + video_height, + }, + ) + } + } +} + pub struct SubsurfacePipeline { speed: f64, pub pipeline: Arc, @@ -30,6 +136,9 @@ impl SubsurfacePipeline { subsurface: &Arc, integration: &WaylandIntegration, bounds: (i32, i32, i32, i32), + compositor_has_cm: bool, + active_subtitle_selection: &Arc>, + subtitle_tx: mpsc::Sender, ) -> Result { gst::init()?; @@ -37,14 +146,8 @@ impl SubsurfacePipeline { .name("playbin3") .property("message-forward", true) .property("async-handling", true) - //.property("connection-speed", 500000u64) - // CRITICAL NOTE: Must be larger than video-sink-bin's queue2 buffer - //.property("buffer-duration", 10_000_000_000i64) // 5s - //.property("buffer-size", 3_000_000i32) - .property("buffer-duration", 6_000_000_000i64) // 5s - //.property("buffer-size", 6_000_000i32) + .property("buffer-duration", 6_000_000_000i64) .property("ring-buffer-max-size", 536870912u64) - //.property("delay", 500_000_000u64) .build() .map_err(|_| Error::Pipeline("Failed to create playbin3 element".to_string()))? .downcast::() @@ -54,140 +157,78 @@ impl SubsurfacePipeline { pipeline.set_property("uri", uri.as_str()); - pipeline.set_property("flags", GstPlayFlags::wayland_native()); + let play_flags = GstPlayFlags::wayland_native(); + log::info!("[pipeline] playbin flags={play_flags}"); + pipeline.set_property("flags", play_flags); + // ── Build waylandsink ────────────────────────────────────────── + // Do NOT set display context or window handle here. + // GStreamer 1.28's waylandsink starts a background Wayland + // event-dispatch thread inside gst_wl_display_new_existing() + // the moment set_context() is called. Doing this while iced's + // event loop is running (we are inside draw()) races with the + // main Wayland event loop and segfaults. + // + // Instead we install a bus *sync handler* (below) that provides + // the display context and window handle exactly when waylandsink + // asks for them during the state transition. This matches the + // official GStreamer waylandsink integration example. let video_sink = gst::ElementFactory::make("waylandsink") .name("vsink") .property("async", true) .property("sync", true) - // Setting too high causes stuttering, will have adjust dynamically to optimize - //.property("blocksize", 500_000u32) .build() .map_err(|err| { - println!("Failed to build video sink: {}", err); - Error::Pipeline("Failed to build video sink".to_string()) + log::error!("Failed to build waylandsink: {}", err); + Error::Pipeline("Failed to build waylandsink".to_string()) })?; - // Create and set the Wayland display context - const WAYLAND_DISPLAY_HANDLE_CONTEXT_TYPE: &str = "GstWaylandDisplayHandleContextType"; - - let mut context = gst::Context::new(WAYLAND_DISPLAY_HANDLE_CONTEXT_TYPE, true); - { - let context = context.get_mut().unwrap(); - let structure = context.structure_mut(); - - log::debug!( - "Setting display pointer in context: {:p}", - integration.display as *const _ - ); - - unsafe { - use glib::translate::{ToGlibPtr, ToGlibPtrMut}; - use gstreamer::ffi as gst_ffi; - - let mut value = glib::Value::from_type(glib::Type::POINTER); - glib::gobject_ffi::g_value_set_pointer( - value.to_glib_none_mut().0, - integration.display, - ); - - gst_ffi::gst_structure_set_value( - structure.as_ptr() as *mut gst_ffi::GstStructure, - c"display".as_ptr(), - value.to_glib_none().0, - ); - } - } - - video_sink.set_context(&context); - log::debug!("Wayland display context set on pipeline"); - - log::debug!("Setting initial subsurface size (will be updated by widget)"); - subsurface.set_position(0, 0); - let init_w = bounds.2.max(1); - let init_h = bounds.3.max(1); - log::info!("[subs] Initial size from bounds: {}x{}", init_w, init_h); - subsurface.set_size(init_w, init_h); // Use provided bounds or minimum 1x1 - // Proactively inform subtitle worker of initial size - - // Now get the surface handle - it should have the correct size - let surface_handle = subsurface.surface_handle(); - log::debug!( - "Setting waylandsink to use subsurface handle: 0x{:x}", - surface_handle - ); - - let video_overlay = video_sink - .dynamic_cast_ref::() - .ok_or_else(|| Error::Pipeline("waylandsink doesn't implement VideoOverlay".into()))?; - - unsafe { - video_overlay.set_window_handle(surface_handle); - if let Err(err) = - video_overlay.set_render_rectangle(bounds.0, bounds.1, bounds.2, bounds.3) - { - log::debug!("[ERROR] Failed to set initial render rectangle: {}", err); - } - video_overlay.expose(); - video_overlay.handle_events(false); - } - - video_sink.set_property("fullscreen", false); // Fullscreen true causes freeze with subsurface + video_sink.set_property("fullscreen", false); if video_sink.has_property("force-aspect-ratio") { video_sink.set_property("force-aspect-ratio", false); } - let vsink_bin = gst::Bin::with_name("waylandsink-bin"); - - // Insert a buffering queue to decouple upstream reconfiguration when subtitles are enabled - //let queue2 = gst::ElementFactory::make("queue2") - // .name("video-buffer") - // .property("use-buffering", true) - // //.property("low-watermark", 0.25f64) - // //.property("high-watermark", 0.85f64) - // .property("max-size-buffers", 20u32) - // //.property("max-size-time", 6_000_000_000u64) // 5s - // //.property("max-size-bytes", 4_000_000u32) - // .property("max-size-bytes", 0u32) - // //.property("ring-buffer-max-size", 536870912u64) - // //.property("use-bitrate-query", true) - // //.property("use-rate-estimate", true) - // .build() - // .map_err(|err| { - // println!("Failed to build video buffer queue2: {}", err); - // Error::Pipeline("Failed to build queue2 for video sink".to_string()) - // })?; - + // ── Build vapostproc ─────────────────────────────────────────── let vapostproc = gst::ElementFactory::make("vapostproc") .name("vapostproc") - // Causes significant artifacting .property("add-borders", false) - // Fixes washed out hdr .property("disable-passthrough", true) .build() .map_err(|err| { - println!("Failed to build video sink: {}", err); - Error::Pipeline("Failed to build video sink".to_string()) + log::error!("Failed to build vapostproc: {}", err); + Error::Pipeline("Failed to build vapostproc".to_string()) })?; - // Should enable tone mapping on supported hardware if vapostproc.has_property("hdr-tone-mapping") { - vapostproc.set_property("hdr-tone-mapping", true); + if compositor_has_cm { + // Compositor supports color management — let HDR pixels pass + // through to waylandsink untouched. The compositor will do + // the tone-mapping using the image description we set on the + // surface via wp-color-management-v1. + vapostproc.set_property("hdr-tone-mapping", false); + log::info!("[pipeline] vapostproc hdr-tone-mapping DISABLED (compositor has CM)"); + } else { + // No compositor CM — vapostproc must tone-map HDR→SDR itself. + vapostproc.set_property("hdr-tone-mapping", true); + log::info!("[pipeline] vapostproc hdr-tone-mapping ENABLED (no compositor CM)"); + } } + // ── Assemble video-sink bin ──────────────────────────────────── + let vsink_bin = gst::Bin::with_name("waylandsink-bin"); + vsink_bin - .add_many([(&vapostproc), &video_sink]) + .add_many([&vapostproc, &video_sink]) .map_err(|e| { Error::Pipeline(format!("Failed to add elements to video-sink bin: {}", e)) })?; - gst::Element::link_many([(&vapostproc), &video_sink]) + gst::Element::link_many([&vapostproc, &video_sink]) .map_err(|e| Error::Pipeline(format!("Failed to link video-sink chain: {}", e)))?; - // Create and add a ghost pad so playbin3 can link video into this bin through the buffer let ghost_pad = gst::GhostPad::with_target(&vapostproc.static_pad("sink").unwrap()) .map_err(|e| { - Error::Pipeline(format!("Failed to create ghost pad for text-sink: {}", e)) + Error::Pipeline(format!("Failed to create ghost pad for video-sink: {}", e)) })?; vsink_bin.add_pad(&ghost_pad).map_err(|e| { @@ -195,18 +236,85 @@ impl SubsurfacePipeline { })?; vsink_bin.set_property("message_forward", true); - // Should still test this vsink_bin.set_property("async-handling", false); pipeline.set_property("video-sink", vsink_bin); + // ── Prepare subsurface geometry ──────────────────────────────── + log::debug!("Setting initial subsurface size (will be updated by widget)"); + subsurface.set_position(0, 0); + let init_w = bounds.2.max(1); + let init_h = bounds.3.max(1); + log::info!("[subs] Initial size from bounds: {}x{}", init_w, init_h); + subsurface.set_size(init_w, init_h); + subsurface.force_damage_and_commit(); subsurface.flush()?; log::debug!("Forced damage and committed subsurface"); - log::debug!("Pipeline ready for playback in PAUSED state"); - // Enable debug subtitle overlay if env var is set - //let debug_subs = std::env::var_os("SUBWAVE_DEBUG_SUBS").is_some(); + // Install the Wayland sink sync handler only after the subsurface has + // valid initial geometry. This follows GStreamer's waylandsink embedding + // pattern: answer NEED_CONTEXT and prepare-window-handle synchronously, + // just-in-time during the state transition, instead of eagerly touching + // waylandsink while iced/winit may be committing the parent surface. + let display_addr = integration.display as usize; + let surface_handle = subsurface.surface_handle(); + let init_bounds = (bounds.0, bounds.1, init_w, init_h); + if let Some(bus) = pipeline.bus() { + bus.set_sync_handler(move |_bus, msg| { + match msg.view() { + gst::MessageView::NeedContext(need_context) => { + let context_type = need_context.context_type(); + if context_type == "GstWaylandDisplayHandleContextType" + || context_type == "GstWlDisplayHandleContextType" + { + log::info!( + "[sync] Providing Wayland display context (type={context_type})" + ); + let context = wayland_display_context(display_addr); + if let Some(src) = msg.src() { + if let Some(element) = src.downcast_ref::() { + element.set_context(&context); + } + } + return gst::BusSyncReply::Drop; + } + } + gst::MessageView::Element(element) => { + let is_prepare_window = element + .structure() + .is_some_and(|s| s.name().as_str() == "prepare-window-handle"); + if is_prepare_window { + log::info!( + "[sync] Providing window handle 0x{surface_handle:x} and render rect {init_bounds:?}" + ); + if let Some(src) = msg.src() { + if let Some(overlay) = src.dynamic_cast_ref::() { + unsafe { + overlay.set_window_handle(surface_handle); + let _ = overlay.set_render_rectangle( + init_bounds.0, + init_bounds.1, + init_bounds.2, + init_bounds.3, + ); + } + } + } + return gst::BusSyncReply::Drop; + } + } + _ => {} + } + gst::BusSyncReply::Pass + }); + } + + Self::install_subtitle_probes(&pipeline, active_subtitle_selection, subtitle_tx); + + log::debug!( + "Pipeline ready (Wayland sync handler installed, scheduled subtitle probes armed)" + ); Ok(Self { speed: 1.0, @@ -214,6 +322,466 @@ impl SubsurfacePipeline { }) } + // ── Scheduled subtitle interception (PGS + text/x-raw) ──────────── + // + // Subtitle buffers are intercepted on demuxer source pads and decoded into + // timestamped cue events. Pad probes never attach to the Wayland subtitle + // surface directly; the UI tick consumes these events through the + // SubtitleScheduler and presents only when media time reaches the cue start. + + fn pad_stream_id(pad: &gst::Pad) -> Option { + pad.sticky_event::(0) + .and_then(|event| { + let stream_id = event.stream_id(); + (!stream_id.is_empty()).then(|| stream_id.to_string()) + }) + } + + fn stream_id_matches(pad_stream_id: &str, selected_stream_id: &str) -> bool { + pad_stream_id == selected_stream_id + || selected_stream_id.ends_with(pad_stream_id) + || pad_stream_id.ends_with(selected_stream_id) + } + + fn classify_pad(pad: &gst::Pad) -> Option { + if pad.direction() != gst::PadDirection::Src { + return None; + } + + let caps = pad.current_caps().or_else(|| { + pad.pad_template() + .map(|template| template.caps().to_owned()) + })?; + + let mut text_match = false; + for structure in caps.iter() { + let name = structure.name(); + if name == "subpicture/x-pgs" || name == "subpicture/x-dvd" { + return Some(SubtitlePadKind::Pgs); + } + if name.as_str().starts_with("text/") { + text_match = true; + } + } + + text_match.then_some(SubtitlePadKind::Text) + } + + fn install_subtitle_probes( + pipeline: &gst::Pipeline, + active_selection: &Arc>, + subtitle_tx: mpsc::Sender, + ) { + let active = Arc::clone(active_selection); + + pipeline.connect_deep_element_added(move |_pipeline, _bin, element| { + let factory_name = element + .factory() + .map(|factory| factory.name().to_string()) + .unwrap_or_default(); + + if !factory_name.contains("demux") { + return; + } + + let element_name = element.name().to_string(); + log::info!("[subs] Watching demuxer {element_name} for subtitle pads"); + + let active_existing = Arc::clone(&active); + let tx_existing = subtitle_tx.clone(); + for pad in element.src_pads() { + Self::maybe_attach_subtitle_probe( + &pad, + &active_existing, + tx_existing.clone(), + &element_name, + ); + } + + let active_dynamic = Arc::clone(&active); + let tx_dynamic = subtitle_tx.clone(); + element.connect_pad_added(move |element, pad| { + if pad.direction() != gst::PadDirection::Src { + return; + } + Self::maybe_attach_subtitle_probe( + pad, + &active_dynamic, + tx_dynamic.clone(), + element.name().as_ref(), + ); + }); + }); + } + + fn maybe_attach_subtitle_probe( + pad: &gst::Pad, + active: &Arc>, + subtitle_tx: mpsc::Sender, + element_name: &str, + ) { + match Self::classify_pad(pad) { + Some(SubtitlePadKind::Pgs) => { + log::info!( + "[subs] Attaching scheduled PGS probe on {element_name}:{}", + pad.name() + ); + Self::attach_pgs_probe(pad, active, subtitle_tx); + } + Some(SubtitlePadKind::Text) => { + log::info!( + "[subs] Attaching scheduled text probe on {element_name}:{}", + pad.name() + ); + Self::attach_text_probe(pad, active, subtitle_tx); + } + None => {} + } + } + + fn active_stream_snapshot( + probe_pad: &gst::Pad, + pad_stream_id: &Mutex>, + active: &Arc>, + log_target: &str, + ) -> Option<(String, u64)> { + // Fast path for startup / subtitles-disabled: do not touch sticky pad + // events or per-pad mutexes until a subtitle stream is actually active. + let (selected_stream_id, generation) = { + let active = active.lock(); + (active.stream_id.clone()?, active.generation) + }; + + let mut stream_id_guard = pad_stream_id.lock().ok()?; + if stream_id_guard.is_none() { + if let Some(stream_id) = Self::pad_stream_id(probe_pad) { + log::info!("[{log_target}] Resolved pad stream-id: {stream_id}"); + *stream_id_guard = Some(stream_id); + } + } + let pad_stream_id = stream_id_guard.clone()?; + drop(stream_id_guard); + + if Self::stream_id_matches(&pad_stream_id, &selected_stream_id) { + Some((selected_stream_id, generation)) + } else { + None + } + } + + fn update_pad_stream_id_from_event( + pad_stream_id: &Mutex>, + stream_start: &gst::event::StreamStart, + log_target: &str, + ) { + let stream_id = stream_start.stream_id(); + if stream_id.is_empty() { + return; + } + + if let Ok(mut guard) = pad_stream_id.lock() { + log::debug!("[{log_target}] STREAM_START stream-id: {stream_id}"); + *guard = Some(stream_id.to_string()); + } + } + + fn update_timing_from_segment( + timing: &Mutex, + segment: &gst::event::Segment, + log_target: &str, + ) { + let Some(time_segment) = segment.segment().downcast_ref::() else { + log::debug!("[{log_target}] Ignoring non-time subtitle segment"); + return; + }; + + if let Ok(mut timing) = timing.lock() { + timing.update_segment(time_segment); + log::debug!("[{log_target}] Updated subtitle segment: {time_segment:?}"); + } + } + + fn buffer_window( + buffer: &gst::BufferRef, + timing: &Mutex, + ) -> Option<(Duration, Option)> { + let timestamp = buffer.pts().or_else(|| buffer.dts())?; + let start = timing + .lock() + .ok() + .and_then(|timing| timing.running_time_for(timestamp)) + .unwrap_or_else(|| duration_from_clock_time(timestamp)); + let end = buffer + .duration() + .and_then(|duration| start.checked_add(duration_from_clock_time(duration))); + Some((start, end)) + } + + fn running_time_for_duration_timestamp( + timestamp: Duration, + timing: &Mutex, + ) -> Option { + let nanos = u64::try_from(timestamp.as_nanos()).ok()?; + let timestamp = gst::ClockTime::from_nseconds(nanos); + timing + .lock() + .ok() + .and_then(|timing| timing.running_time_for(timestamp)) + } + + fn gap_running_time(gap: &gst::event::Gap, timing: &Mutex) -> Duration { + let (timestamp, _) = gap.get(); + timing + .lock() + .ok() + .and_then(|timing| timing.running_time_for(timestamp)) + .unwrap_or_else(|| duration_from_clock_time(timestamp)) + } + + fn send_invalidate_for_active_stream( + probe_pad: &gst::Pad, + pad_stream_id: &Mutex>, + active: &Arc>, + subtitle_tx: &mpsc::Sender, + log_target: &str, + ) { + if let Some((stream_id, generation)) = + Self::active_stream_snapshot(probe_pad, pad_stream_id, active, log_target) + { + let _ = subtitle_tx.send(SubtitleProbeEvent::Invalidate { + stream_id, + generation, + }); + } + } + + fn attach_pgs_probe( + pad: &gst::Pad, + active: &Arc>, + subtitle_tx: mpsc::Sender, + ) { + let active = Arc::clone(active); + let decoder = Mutex::new(crate::pgs_decoder::PgsDecoder::new()); + let pad_stream_id = Mutex::new(Self::pad_stream_id(pad)); + let timing = Mutex::new(PadSubtitleTiming::new()); + + pad.add_probe( + gst::PadProbeType::BUFFER | gst::PadProbeType::EVENT_DOWNSTREAM, + move |probe_pad, info| { + match &info.data { + Some(gst::PadProbeData::Buffer(buffer)) => { + let Some((stream_id, generation)) = Self::active_stream_snapshot( + probe_pad, + &pad_stream_id, + &active, + "pgs-probe", + ) else { + return gst::PadProbeReturn::Ok; + }; + let Some((start, _buffer_end)) = Self::buffer_window(buffer, &timing) + else { + log::debug!("[pgs-probe] Buffer without timestamp; skipping"); + return gst::PadProbeReturn::Ok; + }; + let Ok(map) = buffer.map_readable() else { + return gst::PadProbeReturn::Ok; + }; + + let mut decoder = match decoder.lock() { + Ok(decoder) => decoder, + Err(_) => return gst::PadProbeReturn::Ok, + }; + let display_sets = decoder.feed(map.as_slice()); + let video_width = decoder.video_width; + let video_height = decoder.video_height; + for display_set in display_sets { + let display_start = display_set + .pts + .and_then(|pts| Self::running_time_for_duration_timestamp(pts, &timing)) + .unwrap_or(start); + log::debug!( + "[pgs-probe] scheduling display set: buffer_start={start:?}, raw_pts={:?}, display_start={display_start:?}", + display_set.pts + ); + let event = pgs_display_set_event( + stream_id.clone(), + generation, + display_start, + display_set.action, + video_width, + video_height, + ); + let _ = subtitle_tx.send(SubtitleProbeEvent::Decoded(event)); + } + } + Some(gst::PadProbeData::Event(event)) => match event.view() { + gst::EventView::StreamStart(stream_start) => { + Self::update_pad_stream_id_from_event( + &pad_stream_id, + stream_start, + "pgs-probe", + ); + } + gst::EventView::Segment(segment) => { + if Self::active_stream_snapshot( + probe_pad, + &pad_stream_id, + &active, + "pgs-probe", + ) + .is_some() + { + Self::update_timing_from_segment(&timing, segment, "pgs-probe"); + } + } + gst::EventView::Gap(_) => { + // PGS visibility is encoded by display sets: non-empty PCS + // compositions show bitmaps and zero-object normal PCS display + // sets clear them. GAP events on sparse PGS pads describe packet + // absence/buffering, not the subtitle's presentation end, and can + // arrive immediately after a bitmap display set. + log::debug!( + "[pgs-probe] Ignoring GAP event; PGS display sets carry clear timing" + ); + } + gst::EventView::FlushStart(_) | gst::EventView::FlushStop(_) => { + Self::send_invalidate_for_active_stream( + probe_pad, + &pad_stream_id, + &active, + &subtitle_tx, + "pgs-probe", + ); + } + gst::EventView::Eos(_) => { + Self::send_invalidate_for_active_stream( + probe_pad, + &pad_stream_id, + &active, + &subtitle_tx, + "pgs-probe", + ); + } + _ => {} + }, + _ => {} + } + + gst::PadProbeReturn::Ok + }, + ); + } + + fn attach_text_probe( + pad: &gst::Pad, + active: &Arc>, + subtitle_tx: mpsc::Sender, + ) { + let active = Arc::clone(active); + let pad_stream_id = Mutex::new(Self::pad_stream_id(pad)); + let timing = Mutex::new(PadSubtitleTiming::new()); + + pad.add_probe( + gst::PadProbeType::BUFFER | gst::PadProbeType::EVENT_DOWNSTREAM, + move |probe_pad, info| { + match &info.data { + Some(gst::PadProbeData::Buffer(buffer)) => { + let Some((stream_id, generation)) = Self::active_stream_snapshot( + probe_pad, + &pad_stream_id, + &active, + "text-probe", + ) else { + return gst::PadProbeReturn::Ok; + }; + let Some((start, end)) = Self::buffer_window(buffer, &timing) else { + log::debug!("[text-probe] Buffer without timestamp; skipping"); + return gst::PadProbeReturn::Ok; + }; + let Ok(map) = buffer.map_readable() else { + return gst::PadProbeReturn::Ok; + }; + let text = String::from_utf8_lossy(map.as_slice()).into_owned(); + + let event = if text.trim().is_empty() { + crate::subtitle_scheduler::DecodedSubtitleEvent::clear( + stream_id, generation, start, + ) + } else { + let preview: String = text.chars().take(80).collect(); + log::debug!("[text-probe] queued subtitle: {preview}..."); + crate::subtitle_scheduler::DecodedSubtitleEvent::show( + stream_id, + generation, + start, + end, + WaylandSubtitlePayload::Text(text), + ) + }; + let _ = subtitle_tx.send(SubtitleProbeEvent::Decoded(event)); + } + Some(gst::PadProbeData::Event(event)) => match event.view() { + gst::EventView::StreamStart(stream_start) => { + Self::update_pad_stream_id_from_event( + &pad_stream_id, + stream_start, + "text-probe", + ); + } + gst::EventView::Segment(segment) => { + if Self::active_stream_snapshot( + probe_pad, + &pad_stream_id, + &active, + "text-probe", + ) + .is_some() + { + Self::update_timing_from_segment(&timing, segment, "text-probe"); + } + } + gst::EventView::Gap(gap) => { + if let Some((stream_id, generation)) = Self::active_stream_snapshot( + probe_pad, + &pad_stream_id, + &active, + "text-probe", + ) { + let at = Self::gap_running_time(gap, &timing); + let event = crate::subtitle_scheduler::DecodedSubtitleEvent::clear( + stream_id, generation, at, + ); + let _ = subtitle_tx.send(SubtitleProbeEvent::Decoded(event)); + } + } + gst::EventView::FlushStart(_) | gst::EventView::FlushStop(_) => { + Self::send_invalidate_for_active_stream( + probe_pad, + &pad_stream_id, + &active, + &subtitle_tx, + "text-probe", + ); + } + gst::EventView::Eos(_) => { + Self::send_invalidate_for_active_stream( + probe_pad, + &pad_stream_id, + &active, + &subtitle_tx, + "text-probe", + ); + } + _ => {} + }, + _ => {} + } + + gst::PadProbeReturn::Ok + }, + ); + } + /// Start playback pub fn play(&self) -> Result<()> { let current_state = self.pipeline.current_state(); @@ -250,7 +818,12 @@ impl SubsurfacePipeline { /// Stop playback pub fn stop(&self) -> Result<()> { - // Finally, stop the overall pipeline + // Clear the sync handler before state teardown so waylandsink cannot + // call back into stale Wayland handles while the pipeline is stopping. + if let Some(bus) = self.pipeline.bus() { + bus.unset_sync_handler(); + } + self.pipeline .set_state(gst::State::Null) .map_err(|e| Error::Pipeline(format!("Failed to stop: {:?}", e)))?; @@ -331,7 +904,9 @@ impl SubsurfacePipeline { ); } video_overlay.expose(); - video_overlay.handle_events(true); // Still don't know what this does or if it's necessary + // Do not call `handle_events(true)` here. iced/winit owns the + // Wayland input/event loop; handing events back to waylandsink + // can make the embedded player consume or block UI dispatch. } } } @@ -387,6 +962,11 @@ impl Drop for SubsurfacePipeline { fn drop(&mut self) { log::debug!("Beginning cleanup"); + // Clear the sync handler first to prevent callbacks during teardown. + if let Some(bus) = self.pipeline.bus() { + bus.unset_sync_handler(); + } + // First, stop the pipeline if let Err(e) = self.pipeline.set_state(gst::State::Null) { log::error!("Error: Failed to set state to Null during cleanup: {:?}", e); @@ -398,3 +978,115 @@ impl Drop for SubsurfacePipeline { log::debug!("Cleanup completed"); } } + +#[cfg(test)] +mod tests { + use super::{pgs_display_set_event, WaylandSubtitlePayload}; + use crate::{ + pgs_decoder::{PgsDisplaySet, PgsFrame}, + subtitle_scheduler::{DecodedSubtitleEvent, SubtitleAction, SubtitleScheduler}, + }; + use std::time::Duration; + + const STREAM: &str = "pgs/en"; + + fn ms(value: u64) -> Duration { + Duration::from_millis(value) + } + + #[test] + fn non_empty_pgs_display_sets_have_no_scheduled_end() { + let frames = vec![PgsFrame { + argb: vec![0, 0, 0, 255], + width: 1, + height: 1, + x: 10, + y: 20, + }]; + + assert_eq!( + pgs_display_set_event( + STREAM.to_string(), + 3, + ms(1_000), + PgsDisplaySet::Show(frames.clone()), + 1920, + 1080 + ), + DecodedSubtitleEvent::show( + STREAM, + 3, + ms(1_000), + None, + WaylandSubtitlePayload::Pgs { + frames, + video_width: 1920, + video_height: 1080, + }, + ) + ); + } + + #[test] + fn empty_pgs_display_sets_clear_at_their_timestamp() { + assert_eq!( + pgs_display_set_event( + STREAM.to_string(), + 3, + ms(2_500), + PgsDisplaySet::Clear, + 1920, + 1080 + ), + DecodedSubtitleEvent::clear(STREAM, 3, ms(2_500)) + ); + } + + #[test] + fn open_ended_pgs_cue_persists_until_empty_display_set_clear() { + let mut scheduler = SubtitleScheduler::new(STREAM, 3); + let payload = WaylandSubtitlePayload::Pgs { + frames: vec![PgsFrame { + argb: vec![0, 0, 0, 255], + width: 1, + height: 1, + x: 0, + y: 0, + }], + video_width: 1920, + video_height: 1080, + }; + + assert!(scheduler.push_event(DecodedSubtitleEvent::show( + STREAM, + 3, + ms(1_000), + None, + payload.clone(), + ))); + assert_eq!( + scheduler.drain_due(ms(1_000)), + vec![SubtitleAction::Attach( + crate::subtitle_scheduler::SubtitleAttach { + stream_id: STREAM.to_string(), + generation: 3, + start: ms(1_000), + end: None, + payload, + } + )] + ); + assert!(scheduler.drain_due(ms(2_499)).is_empty()); + + assert!(scheduler.push_event(DecodedSubtitleEvent::clear(STREAM, 3, ms(2_500)))); + assert_eq!( + scheduler.drain_due(ms(2_500)), + vec![SubtitleAction::Clear( + crate::subtitle_scheduler::SubtitleClearAction { + stream_id: STREAM.to_string(), + generation: 3, + }, + )] + ); + } +} diff --git a/subwave_wayland/src/subsurface_manager.rs b/subwave_wayland/src/subsurface_manager.rs index 12ad4b3..fcca69b 100644 --- a/subwave_wayland/src/subsurface_manager.rs +++ b/subwave_wayland/src/subsurface_manager.rs @@ -20,6 +20,8 @@ use wayland_protocols::wp::viewporter::client::{ wp_viewport::WpViewport, wp_viewporter::WpViewporter, }; +use crate::color_management::ColorManager; + /// Manages a Wayland subsurface for video rendering pub struct WaylandSubsurfaceManager { /// The Wayland connection (shared with parent) @@ -85,6 +87,11 @@ pub struct WaylandSubsurfaceManager { subtitle_pool: Mutex>, subtitle_file: Mutex>, subtitle_pool_dims: Mutex>, // (w,h,stride) + + /// Color management (wp-color-management-v1) for per-surface HDR/SDR tagging. + /// When available, the video surface is tagged BT.2020+PQ and the subtitle + /// surface is tagged sRGB, so the compositor can tone-map each independently. + color_manager: Mutex>, } impl std::fmt::Debug for WaylandSubsurfaceManager { @@ -102,14 +109,19 @@ impl std::fmt::Debug for WaylandSubsurfaceManager { } /// State for Wayland event dispatching -struct State { - globals: Vec<(u32, String, u32)>, // (name, interface, version) +pub(crate) struct State { + pub(crate) globals: Vec<(u32, String, u32)>, // (name, interface, version) + /// Color management feature flags (populated by wp_color_manager_v1 events) + pub(crate) cm_supports_set_luminances: bool, + pub(crate) cm_supports_set_mastering_primaries: bool, } impl State { - fn new() -> Self { + pub(crate) fn new() -> Self { Self { globals: Vec::new(), + cm_supports_set_luminances: false, + cm_supports_set_mastering_primaries: false, } } } @@ -193,6 +205,27 @@ impl WaylandSubsurfaceManager { None }; + // ── Color management (optional) ────────────────────────────── + // We only bind the global here. Actual image descriptions are + // created lazily when the video caps indicate HDR content (see + // `notify_video_colorimetry`). The subtitle surface is + // deliberately left untagged so the compositor defaults to sRGB. + let mut color_manager = ColorManager::bind_if_available(&state.globals, ®istry, &qh); + if let Some(ref mut cm) = color_manager { + // Roundtrip to receive the capability events (supported TFs, features, etc.) + event_queue.roundtrip(&mut state).map_err(|e| { + Error::Wayland(format!("Failed to roundtrip for color-mgmt: {}", e)) + })?; + // Transfer the feature flags that the Dispatch handler stored in State + cm.supports_set_luminances = state.cm_supports_set_luminances; + cm.supports_set_mastering_primaries = state.cm_supports_set_mastering_primaries; + log::info!( + "[color-mgmt] Feature flags: luminances={}, mastering_primaries={}", + cm.supports_set_luminances, + cm.supports_set_mastering_primaries, + ); + } + // Create a proxy for the parent surface without taking ownership // The parent surface is already managed by winit/iced log::debug!( @@ -286,16 +319,21 @@ impl WaylandSubsurfaceManager { subcompositor.get_subsurface(&subtitle_surface, &parent_surface, &qh, ()); log::debug!("Created subtitle subsurface"); - // Set to desynchronized mode for independent video and subtitle updates + // Video subsurface: desync so GStreamer's waylandsink can commit + // frames independently on its streaming thread. video_subsurface.set_desync(); - subtitle_subsurface.set_desync(); - log::debug!( - "Set video and subtitle subsurfaces to desync mode for independent updates" - ); - // Background surface can be synchronized with parent since it only needs to change on resize + // Subtitle subsurface: SYNC mode. Changes are applied atomically + // when the parent surface (iced) commits. This prevents the green + // flash on Hyprland's HDR CM pipeline — desync subtitle commits + // cause the compositor to re-composite asynchronously, triggering + // a transient color-management rendering glitch. mpv uses the + // same approach: all surfaces are committed together. + subtitle_subsurface.set_sync(); + + // Background: sync with parent (only changes on resize). background_subsurface.set_sync(); - log::debug!("Set background subsurface to sync mode"); + log::debug!("Subsurface modes: video=desync, subtitle=sync, background=sync"); // Z-ordering: video below parent, subtitle above parent video_subsurface.place_below(&parent_surface); @@ -350,6 +388,7 @@ impl WaylandSubsurfaceManager { subtitle_pool: Mutex::new(None), subtitle_file: Mutex::new(None), subtitle_pool_dims: Mutex::new(None), + color_manager: Mutex::new(color_manager), }); // Create initial background buffer @@ -476,7 +515,20 @@ impl WaylandSubsurfaceManager { if self.shm.is_none() { return Err(Error::Wayland("No wl_shm for subtitle".into())); } + if width <= 0 || height <= 0 || stride <= 0 { + return Ok(()); // degenerate frame, skip + } let needed = (stride as usize) * (height as usize); + if data.len() < needed { + return Err(Error::Wayland(format!( + "Subtitle data too small: {} < {} ({}x{} stride={})", + data.len(), + needed, + width, + height, + stride + ))); + } log::debug!( "[subs] attach_subtitle_frame called: {}x{} stride={} ({} bytes)", width, @@ -737,6 +789,68 @@ impl WaylandSubsurfaceManager { handle } + /// Returns `true` if the compositor supports `wp-color-management-v1`. + pub fn has_color_management(&self) -> bool { + self.color_manager.lock().is_some() + } + + /// Returns `true` if the video surface is currently tagged with an HDR + /// image description. When true, the compositor tone-maps the subtitle + /// surface (sRGB by default) independently of the HDR video surface. + pub fn is_video_tagged_hdr(&self) -> bool { + self.color_manager + .lock() + .as_ref() + .is_some_and(|cm| cm.is_video_tagged_hdr()) + } + + /// Notify the subsurface manager of the current video stream's colorimetry. + /// + /// When the video is HDR (PQ/HLG transfer) and the compositor supports + /// color management, this tags the video surface with a BT.2020+PQ image + /// description. When the video is SDR, any HDR tag is removed so the + /// compositor treats the video surface as sRGB. + /// + /// `colorimetry` is the GStreamer colorimetry string (e.g. `"0:0:14:7"`). + /// `metadata` contains optional mastering display / content light level info. + /// + /// This is safe to call on every caps change — it no-ops if the colorimetry + /// hasn't changed. + pub fn notify_video_colorimetry( + &self, + colorimetry: &str, + metadata: Option<&crate::color_management::HdrMetadata>, + ) { + let mut cm_lock = self.color_manager.lock(); + let Some(ref mut cm) = *cm_lock else { + return; // no color management support + }; + + let is_hdr = crate::color_management::HdrMetadata::is_hdr_colorimetry(colorimetry); + + if is_hdr { + // Take a single lock on the event queue for both the handle and the roundtrip + let mut eq = self.event_queue.lock(); + let qh = eq.handle(); + match cm.tag_video_hdr(colorimetry, metadata, &self.video_surface, &qh, &mut eq) { + Ok(()) => { + drop(eq); + if let Err(e) = self.flush() { + log::warn!("[color-mgmt] Flush after HDR tag failed: {e}"); + } + } + Err(e) => { + log::warn!("[color-mgmt] Failed to tag video HDR (non-fatal): {e}"); + } + } + } else { + cm.untag_video(&self.video_surface); + if let Err(e) = self.flush() { + log::warn!("[color-mgmt] Flush after untag failed: {e}"); + } + } + } + /// Flush any pending Wayland events pub fn flush(&self) -> Result<()> { self.event_queue @@ -917,6 +1031,11 @@ impl Drop for WaylandSubsurfaceManager { } self.subtitle_file.lock().take(); + // Destroy color management resources before surfaces + if let Some(mut cm) = self.color_manager.lock().take() { + cm.destroy(); + } + // Destroy viewports if they exist if let Some(ref viewport) = self.video_viewport { viewport.destroy(); diff --git a/subwave_wayland/src/subtitle_runtime.rs b/subwave_wayland/src/subtitle_runtime.rs new file mode 100644 index 0000000..4c17466 --- /dev/null +++ b/subwave_wayland/src/subtitle_runtime.rs @@ -0,0 +1,138 @@ +use std::time::Duration; + +use crate::{ + pgs_decoder::PgsFrame, + subtitle_scheduler::{DecodedSubtitleEvent, SubtitleAction, SubtitleScheduler}, +}; + +/// The subtitle stream currently allowed to enqueue Wayland subtitle cues. +#[derive(Debug, Default)] +pub(crate) struct ActiveSubtitleSelection { + pub(crate) stream_id: Option, + pub(crate) generation: u64, +} + +impl ActiveSubtitleSelection { + pub(crate) fn set_stream(&mut self, stream_id: Option) -> u64 { + self.generation = self.generation.saturating_add(1); + self.stream_id = stream_id; + self.generation + } + + pub(crate) fn flush(&mut self) -> u64 { + self.generation = self.generation.saturating_add(1); + self.generation + } +} + +/// Subtitle payloads decoded by GStreamer pad probes and presented later on the UI tick. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum WaylandSubtitlePayload { + /// One complete PGS display set, in original PGS/video coordinates. + Pgs { + frames: Vec, + video_width: u16, + video_height: u16, + }, + /// A text cue that should be rendered at presentation time using the current surface size. + Text(String), +} + +/// A fully rasterized subtitle buffer ready for `wl_shm` attachment. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SubtitleBitmap { + pub(crate) data: Vec, + pub(crate) width: i32, + pub(crate) height: i32, + pub(crate) stride: i32, +} + +/// Events sent from subtitle pad probes to the UI/tick thread. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SubtitleProbeEvent { + Decoded(DecodedSubtitleEvent), + /// A downstream flush/seek/rate discontinuity invalidated pending cues for this generation. + Invalidate { + stream_id: String, + generation: u64, + }, +} + +pub(crate) type WaylandSubtitleScheduler = SubtitleScheduler; +pub(crate) type WaylandSubtitleAction = SubtitleAction; + +pub(crate) fn compose_pgs_bitmap( + frames: &[PgsFrame], + pgs_width: u16, + pgs_height: u16, + surface_width: i32, + surface_height: i32, +) -> Option { + if frames.is_empty() { + return None; + } + + let surf_w = surface_width.max(1) as usize; + let surf_h = surface_height.max(1) as usize; + let pgs_w = pgs_width.max(1) as f64; + let pgs_h = pgs_height.max(1) as f64; + let scale_x = surf_w as f64 / pgs_w; + let scale_y = surf_h as f64 / pgs_h; + let stride = surf_w * 4; + let mut canvas = vec![0u8; stride * surf_h]; + + for frame in frames { + let frame_w = frame.width as usize; + let frame_h = frame.height as usize; + if frame_w == 0 || frame_h == 0 { + continue; + } + + let fx = (frame.x as f64 * scale_x) as usize; + let fy = (frame.y as f64 * scale_y) as usize; + let scaled_fw = ((frame.width as f64) * scale_x).ceil().max(1.0) as usize; + let scaled_fh = ((frame.height as f64) * scale_y).ceil().max(1.0) as usize; + let src_stride = frame_w * 4; + + for dy in 0..scaled_fh { + let canvas_y = fy + dy; + if canvas_y >= surf_h { + break; + } + let src_row = ((dy as f64) / scale_y).floor() as usize; + if src_row >= frame_h { + continue; + } + let src_row_offset = src_row * src_stride; + + for dx in 0..scaled_fw { + let canvas_x = fx + dx; + if canvas_x >= surf_w { + break; + } + let src_col = ((dx as f64) / scale_x).floor() as usize; + if src_col >= frame_w { + continue; + } + + let src_offset = src_row_offset + src_col * 4; + let dst_offset = canvas_y * stride + canvas_x * 4; + if src_offset + 4 <= frame.argb.len() && dst_offset + 4 <= canvas.len() { + canvas[dst_offset..dst_offset + 4] + .copy_from_slice(&frame.argb[src_offset..src_offset + 4]); + } + } + } + } + + Some(SubtitleBitmap { + data: canvas, + width: surf_w as i32, + height: surf_h as i32, + stride: stride as i32, + }) +} + +pub(crate) fn duration_from_clock_time(clock_time: gstreamer::ClockTime) -> Duration { + Duration::from_nanos(clock_time.nseconds()) +} diff --git a/subwave_wayland/src/subtitle_scheduler.rs b/subwave_wayland/src/subtitle_scheduler.rs new file mode 100644 index 0000000..dca8f8d --- /dev/null +++ b/subwave_wayland/src/subtitle_scheduler.rs @@ -0,0 +1,651 @@ +//! Clocked subtitle scheduling for Wayland subtitle presentation. +//! +//! The scheduler is intentionally independent of Wayland and GStreamer so subtitle timing behavior +//! can be covered with unit tests. Callers feed decoded subtitle events in running-time units and +//! poll for attach/clear actions using the current media running time. + +use std::{sync::OnceLock, time::Duration}; + +/// A decoded subtitle event addressed to a selected subtitle stream generation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DecodedSubtitleEvent

{ + /// Present a decoded subtitle payload during the given running-time interval. + Show(DecodedSubtitleCue

), + /// Clear the subtitle surface at the given running time. + Clear(DecodedSubtitleClear), +} + +impl

DecodedSubtitleEvent

{ + /// Build a decoded subtitle show event. + pub fn show( + stream_id: impl Into, + generation: u64, + start: Duration, + end: Option, + payload: P, + ) -> Self { + Self::Show(DecodedSubtitleCue { + stream_id: stream_id.into(), + generation, + start, + end, + payload, + }) + } + + /// Build a decoded subtitle clear event. + pub fn clear(stream_id: impl Into, generation: u64, at: Duration) -> Self { + Self::Clear(DecodedSubtitleClear { + stream_id: stream_id.into(), + generation, + at, + }) + } +} + +/// A decoded subtitle payload and its running-time presentation window. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DecodedSubtitleCue

{ + pub stream_id: String, + pub generation: u64, + pub start: Duration, + pub end: Option, + pub payload: P, +} + +/// A decoded subtitle clear event. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DecodedSubtitleClear { + pub stream_id: String, + pub generation: u64, + pub at: Duration, +} + +/// A subtitle action that is due for the renderer at the current media time. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SubtitleAction

{ + /// Attach a decoded subtitle payload to the subtitle surface. + Attach(SubtitleAttach

), + /// Detach any current subtitle payload from the subtitle surface. + Clear(SubtitleClearAction), +} + +/// A due subtitle attach action. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubtitleAttach

{ + pub stream_id: String, + pub generation: u64, + pub start: Duration, + pub end: Option, + pub payload: P, +} + +/// A due subtitle clear action. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubtitleClearAction { + pub stream_id: String, + pub generation: u64, +} + +/// Clocked scheduler for decoded subtitle presentation. +/// +/// The scheduler tracks one selected subtitle stream and generation. Events for older generations +/// or non-selected streams are ignored, which lets seek/flush and track-switch operations invalidate +/// already decoded-but-stale subtitle buffers without involving a Wayland compositor. +#[derive(Debug)] +pub struct SubtitleScheduler

{ + stream_id: String, + generation: u64, + next_sequence: u64, + active: Option, + pending: Vec>, +} + +impl

SubtitleScheduler

{ + /// Create a scheduler for the currently selected subtitle stream generation. + pub fn new(stream_id: impl Into, generation: u64) -> Self { + Self { + stream_id: stream_id.into(), + generation, + next_sequence: 0, + active: None, + pending: Vec::new(), + } + } + + /// Queue a decoded subtitle event. + /// + /// Returns `false` when the event belongs to a stale generation, a non-selected stream, or an + /// empty/negative presentation interval. + pub fn push_event(&mut self, event: DecodedSubtitleEvent

) -> bool { + match event { + DecodedSubtitleEvent::Show(cue) => self.push_show(cue), + DecodedSubtitleEvent::Clear(clear) => self.push_clear(clear), + } + } + + /// Advance the selected stream generation after a seek or decoder flush. + /// + /// Pending events are discarded. If a subtitle is currently attached, a clear action is returned + /// so the renderer can remove stale subtitle pixels immediately. + pub fn flush_generation(&mut self, generation: u64) -> Option> { + self.generation = generation; + self.pending.clear(); + self.active.take().map(clear_action_for_active) + } + + /// Switch to a different subtitle stream and generation. + /// + /// Pending events from the previous stream are discarded. If a subtitle is currently attached, a + /// clear action is returned so track changes do not leave stale subtitle pixels visible. + pub(crate) fn switch_stream( + &mut self, + stream_id: impl Into, + generation: u64, + ) -> Option> { + self.stream_id = stream_id.into(); + self.generation = generation; + self.pending.clear(); + self.active.take().map(clear_action_for_active) + } + + /// Drain all actions due at or before `running_time`. + pub fn drain_due(&mut self, running_time: Duration) -> Vec> { + let mut actions = Vec::new(); + + loop { + let active_due = self.active.as_ref().and_then(|active| { + active.end.and_then(|end| { + (end <= running_time).then_some(DueOrder { + time: end, + priority: DuePriority::Clear, + sequence: active.sequence, + }) + }) + }); + + let pending_due = self.next_pending_due(running_time); + + match (active_due, pending_due) { + (None, None) => break, + (Some(_), None) => { + if let Some(active) = self.active.take() { + actions.push(clear_action_for_active(active)); + } + } + (None, Some((index, _))) => { + self.apply_pending_event(index, running_time, &mut actions); + } + (Some(active_order), Some((index, pending_order))) => { + if active_order <= pending_order { + if let Some(active) = self.active.take() { + actions.push(clear_action_for_active(active)); + } + } else { + self.apply_pending_event(index, running_time, &mut actions); + } + } + } + } + + actions + } + + fn push_show(&mut self, cue: DecodedSubtitleCue

) -> bool { + if !self.is_current(&cue.stream_id, cue.generation) { + return false; + } + + if cue.end.is_some_and(|end| end <= cue.start) { + return false; + } + + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.saturating_add(1); + self.pending + .push(PendingEvent::Show(PendingShow { sequence, cue })); + true + } + + fn push_clear(&mut self, clear: DecodedSubtitleClear) -> bool { + if !self.is_current(&clear.stream_id, clear.generation) { + return false; + } + + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.saturating_add(1); + self.pending + .push(PendingEvent::Clear(PendingClear { sequence, clear })); + true + } + + fn is_current(&self, stream_id: &str, generation: u64) -> bool { + self.stream_id == stream_id && self.generation == generation + } + + fn next_pending_due(&self, running_time: Duration) -> Option<(usize, DueOrder)> { + self.pending + .iter() + .enumerate() + .filter_map(|(index, event)| { + let order = event.due_order(); + (order.time <= running_time).then_some((index, order)) + }) + .min_by_key(|(_, order)| *order) + } + + fn apply_pending_event( + &mut self, + index: usize, + running_time: Duration, + actions: &mut Vec>, + ) { + match self.pending.remove(index) { + PendingEvent::Show(pending) => self.apply_show(pending, running_time, actions), + PendingEvent::Clear(pending) => self.apply_clear(pending, actions), + } + } + + fn apply_show( + &mut self, + pending: PendingShow

, + running_time: Duration, + actions: &mut Vec>, + ) { + let cue = pending.cue; + if !self.is_current(&cue.stream_id, cue.generation) { + return; + } + + if cue.end.is_some_and(|end| end <= running_time) { + return; + } + + log_attach_timing(&cue, running_time); + + self.active = Some(ActiveSubtitle { + sequence: pending.sequence, + stream_id: cue.stream_id.clone(), + generation: cue.generation, + start: cue.start, + end: cue.end, + }); + + actions.push(SubtitleAction::Attach(SubtitleAttach { + stream_id: cue.stream_id, + generation: cue.generation, + start: cue.start, + end: cue.end, + payload: cue.payload, + })); + } + + fn apply_clear(&mut self, pending: PendingClear, actions: &mut Vec>) { + let clear = pending.clear; + if !self.is_current(&clear.stream_id, clear.generation) { + return; + } + + let should_clear = self.active.as_ref().is_some_and(|active| { + active.stream_id == clear.stream_id + && active.generation == clear.generation + && active.start <= clear.at + }); + + if !should_clear { + return; + } + + let active = self + .active + .take() + .expect("active subtitle exists when clear action is due"); + actions.push(clear_action_for_active(active)); + } +} + +#[derive(Clone, Debug)] +struct ActiveSubtitle { + sequence: u64, + stream_id: String, + generation: u64, + start: Duration, + end: Option, +} + +#[derive(Clone, Debug)] +struct PendingShow

{ + sequence: u64, + cue: DecodedSubtitleCue

, +} + +#[derive(Clone, Debug)] +struct PendingClear { + sequence: u64, + clear: DecodedSubtitleClear, +} + +#[derive(Clone, Debug)] +enum PendingEvent

{ + Show(PendingShow

), + Clear(PendingClear), +} + +impl

PendingEvent

{ + fn due_order(&self) -> DueOrder { + match self { + Self::Show(show) => DueOrder { + time: show.cue.start, + priority: DuePriority::Attach, + sequence: show.sequence, + }, + Self::Clear(clear) => DueOrder { + time: clear.clear.at, + priority: DuePriority::Clear, + sequence: clear.sequence, + }, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct DueOrder { + time: Duration, + priority: DuePriority, + sequence: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +enum DuePriority { + Clear, + Attach, +} + +const SUBTITLE_SYNC_LOG_TARGET: &str = "subwave_wayland::subtitle_sync"; +const SUBTITLE_SYNC_DEBUG_ENV: &str = "SUBWAVE_WAYLAND_SUBTITLE_SYNC_DEBUG"; + +fn log_attach_timing

(cue: &DecodedSubtitleCue

, presentation_time: Duration) { + if !subtitle_sync_debug_enabled() + || !log::log_enabled!(target: SUBTITLE_SYNC_LOG_TARGET, log::Level::Debug) + { + return; + } + + log::debug!( + target: SUBTITLE_SYNC_LOG_TARGET, + "[subs-sync] attach stream={} generation={} cue_start_ms={:.3} \ + presentation_media_time_ms={:.3} delta_ms={:+.3}", + cue.stream_id, + cue.generation, + duration_ms(cue.start), + duration_ms(presentation_time), + signed_delta_ms(presentation_time, cue.start), + ); +} + +fn subtitle_sync_debug_enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| { + std::env::var(SUBTITLE_SYNC_DEBUG_ENV).is_ok_and(|value| env_flag_enabled(&value)) + }) +} + +fn env_flag_enabled(value: &str) -> bool { + let value = value.trim(); + value == "1" + || value.eq_ignore_ascii_case("true") + || value.eq_ignore_ascii_case("yes") + || value.eq_ignore_ascii_case("on") +} + +fn duration_ms(duration: Duration) -> f64 { + duration.as_secs_f64() * 1_000.0 +} + +fn signed_delta_ms(presentation_time: Duration, cue_start: Duration) -> f64 { + if presentation_time >= cue_start { + duration_ms(presentation_time - cue_start) + } else { + -duration_ms(cue_start - presentation_time) + } +} + +fn clear_action_for_active

(active: ActiveSubtitle) -> SubtitleAction

{ + SubtitleAction::Clear(SubtitleClearAction { + stream_id: active.stream_id, + generation: active.generation, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + env_flag_enabled, signed_delta_ms, DecodedSubtitleEvent, SubtitleAction, SubtitleAttach, + SubtitleClearAction, SubtitleScheduler, + }; + use std::time::Duration; + + const STREAM: &str = "text/en"; + const ALT_STREAM: &str = "text/es"; + + fn ms(value: u64) -> Duration { + Duration::from_millis(value) + } + + fn show( + text: &'static str, + start_ms: u64, + end_ms: Option, + generation: u64, + ) -> DecodedSubtitleEvent<&'static str> { + DecodedSubtitleEvent::show(STREAM, generation, ms(start_ms), end_ms.map(ms), text) + } + + fn alt_show( + text: &'static str, + start_ms: u64, + end_ms: Option, + generation: u64, + ) -> DecodedSubtitleEvent<&'static str> { + DecodedSubtitleEvent::show(ALT_STREAM, generation, ms(start_ms), end_ms.map(ms), text) + } + + fn clear(at_ms: u64, generation: u64) -> DecodedSubtitleEvent<&'static str> { + DecodedSubtitleEvent::clear(STREAM, generation, ms(at_ms)) + } + + fn attach( + text: &'static str, + start_ms: u64, + end_ms: Option, + generation: u64, + ) -> SubtitleAction<&'static str> { + SubtitleAction::Attach(SubtitleAttach { + stream_id: STREAM.to_string(), + generation, + start: ms(start_ms), + end: end_ms.map(ms), + payload: text, + }) + } + + fn alt_attach( + text: &'static str, + start_ms: u64, + end_ms: Option, + generation: u64, + ) -> SubtitleAction<&'static str> { + SubtitleAction::Attach(SubtitleAttach { + stream_id: ALT_STREAM.to_string(), + generation, + start: ms(start_ms), + end: end_ms.map(ms), + payload: text, + }) + } + + fn clear_action(generation: u64) -> SubtitleAction<&'static str> { + SubtitleAction::Clear(SubtitleClearAction { + stream_id: STREAM.to_string(), + generation, + }) + } + + fn alt_clear_action(generation: u64) -> SubtitleAction<&'static str> { + SubtitleAction::Clear(SubtitleClearAction { + stream_id: ALT_STREAM.to_string(), + generation, + }) + } + + #[test] + fn early_cue_arrival_is_not_due_before_start() { + let mut scheduler = SubtitleScheduler::new(STREAM, 0); + + assert!(scheduler.push_event(show("hello", 1_000, Some(2_000), 0))); + + assert!(scheduler.drain_due(ms(999)).is_empty()); + } + + #[test] + fn late_tick_attaches_overdue_cue_with_expected_delta() { + let mut scheduler = SubtitleScheduler::new(STREAM, 0); + + assert!(scheduler.push_event(show("hello", 1_000, Some(2_000), 0))); + + assert_eq!(signed_delta_ms(ms(1_250), ms(1_000)), 250.0); + assert_eq!( + scheduler.drain_due(ms(1_250)), + vec![attach("hello", 1_000, Some(2_000), 0)] + ); + assert!(scheduler.drain_due(ms(1_250)).is_empty()); + } + + #[test] + fn sync_debug_flag_accepts_explicit_truthy_values_only() { + assert!(env_flag_enabled("1")); + assert!(env_flag_enabled(" true ")); + assert!(env_flag_enabled("YES")); + assert!(env_flag_enabled("on")); + assert!(!env_flag_enabled("")); + assert!(!env_flag_enabled("0")); + assert!(!env_flag_enabled("false")); + } + + #[test] + fn cue_start_attaches_when_running_time_reaches_start() { + let mut scheduler = SubtitleScheduler::new(STREAM, 0); + + assert!(scheduler.push_event(show("hello", 1_000, Some(2_000), 0))); + + assert_eq!( + scheduler.drain_due(ms(1_000)), + vec![attach("hello", 1_000, Some(2_000), 0)] + ); + } + + #[test] + fn cue_end_clears_the_active_subtitle() { + let mut scheduler = SubtitleScheduler::new(STREAM, 0); + + assert!(scheduler.push_event(show("hello", 0, Some(1_000), 0))); + assert_eq!( + scheduler.drain_due(ms(0)), + vec![attach("hello", 0, Some(1_000), 0)] + ); + assert!(scheduler.drain_due(ms(999)).is_empty()); + + assert_eq!(scheduler.drain_due(ms(1_000)), vec![clear_action(0)]); + } + + #[test] + fn explicit_clear_event_clears_the_active_subtitle() { + let mut scheduler = SubtitleScheduler::new(STREAM, 0); + + assert!(scheduler.push_event(show("hello", 0, None, 0))); + assert_eq!( + scheduler.drain_due(ms(0)), + vec![attach("hello", 0, None, 0)] + ); + assert!(scheduler.push_event(clear(500, 0))); + assert!(scheduler.drain_due(ms(499)).is_empty()); + + assert_eq!(scheduler.drain_due(ms(500)), vec![clear_action(0)]); + } + + #[test] + fn overlapping_cue_replaces_active_without_old_end_clearing_replacement() { + let mut scheduler = SubtitleScheduler::new(STREAM, 0); + + assert!(scheduler.push_event(show("first", 0, Some(5_000), 0))); + assert!(scheduler.push_event(show("second", 3_000, Some(7_000), 0))); + + assert_eq!( + scheduler.drain_due(ms(0)), + vec![attach("first", 0, Some(5_000), 0)] + ); + assert_eq!( + scheduler.drain_due(ms(3_000)), + vec![attach("second", 3_000, Some(7_000), 0)] + ); + assert!(scheduler.drain_due(ms(5_000)).is_empty()); + assert_eq!(scheduler.drain_due(ms(7_000)), vec![clear_action(0)]); + } + + #[test] + fn seek_flush_generation_discards_pending_and_rejects_stale_events() { + let mut scheduler = SubtitleScheduler::new(STREAM, 0); + + assert!(scheduler.push_event(show("before seek", 1_000, Some(2_000), 0))); + let clear = scheduler.flush_generation(1); + + assert!(clear.is_none()); + assert!(scheduler.drain_due(ms(1_000)).is_empty()); + assert!(!scheduler.push_event(show("stale", 1_000, Some(2_000), 0))); + assert!(scheduler.push_event(show("after seek", 1_500, Some(2_500), 1))); + + assert_eq!( + scheduler.drain_due(ms(1_500)), + vec![attach("after seek", 1_500, Some(2_500), 1)] + ); + } + + #[test] + fn seek_flush_generation_clears_active_subtitle() { + let mut scheduler = SubtitleScheduler::new(STREAM, 0); + + assert!(scheduler.push_event(show("before seek", 0, Some(2_000), 0))); + assert_eq!( + scheduler.drain_due(ms(0)), + vec![attach("before seek", 0, Some(2_000), 0)] + ); + + assert_eq!(scheduler.flush_generation(1), Some(clear_action(0))); + assert!(scheduler.drain_due(ms(2_000)).is_empty()); + } + + #[test] + fn track_switch_clears_active_and_invalidates_old_stream_events() { + let mut scheduler = SubtitleScheduler::new(STREAM, 0); + + assert!(scheduler.push_event(show("english", 0, Some(2_000), 0))); + assert!(scheduler.push_event(show("queued english", 750, Some(1_250), 0))); + assert_eq!( + scheduler.drain_due(ms(0)), + vec![attach("english", 0, Some(2_000), 0)] + ); + + assert_eq!( + scheduler.switch_stream(ALT_STREAM, 0), + Some(clear_action(0)) + ); + assert!(!scheduler.push_event(show("stale english", 500, Some(1_000), 0))); + assert!(scheduler.push_event(alt_show("spanish", 500, Some(1_000), 0))); + + assert_eq!( + scheduler.drain_due(ms(750)), + vec![alt_attach("spanish", 500, Some(1_000), 0)] + ); + assert_eq!(scheduler.drain_due(ms(1_000)), vec![alt_clear_action(0)]); + } +} diff --git a/subwave_wayland/src/text_renderer.rs b/subwave_wayland/src/text_renderer.rs new file mode 100644 index 0000000..9e854bc --- /dev/null +++ b/subwave_wayland/src/text_renderer.rs @@ -0,0 +1,366 @@ +//! Render subtitle text strings to pre-multiplied ARGB8888 bitmaps. +//! +//! Uses `ab_glyph` with a system TrueType font. The rendered output is +//! a full-canvas-sized transparent image with white text and a dark +//! shadow, positioned at the bottom-center — ready for Wayland subtitle +//! subsurface attachment. +//! +//! Rendering is designed to be fast enough to run from the UI/tick path when +//! a scheduled text cue becomes due. No per-pixel dilation or multi-pass +//! filters — just two glyph passes (shadow + foreground). + +use ab_glyph::{point, Font, FontRef, Glyph, PxScale, ScaleFont}; +use once_cell::sync::OnceCell; + +/// Common system font paths (bold preferred for subtitle readability). +const FONT_SEARCH_PATHS: &[&str] = &[ + // Debian / Ubuntu + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + // Arch / Fedora + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/liberation-sans/LiberationSans-Bold.ttf", + // Noto + "/usr/share/fonts/noto/NotoSans-Bold.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", + // NixOS (Nix store symlink farms) + "/run/current-system/sw/share/X11/fonts/DejaVuSans-Bold.ttf", + "/run/current-system/sw/share/X11/fonts/DejaVuSans.ttf", +]; + +static FONT_DATA: OnceCell> = OnceCell::new(); + +fn load_font_data() -> Option<&'static [u8]> { + FONT_DATA + .get_or_try_init(|| { + for path in FONT_SEARCH_PATHS { + if let Ok(data) = std::fs::read(path) { + log::info!("[text-renderer] Loaded font: {path}"); + return Ok(data); + } + } + // Also try fc-match as a last resort + if let Ok(output) = std::process::Command::new("fc-match") + .args(["--format=%{file}", "sans:bold"]) + .output() + { + let path = String::from_utf8_lossy(&output.stdout); + let path = path.trim(); + if !path.is_empty() { + if let Ok(data) = std::fs::read(path) { + log::info!("[text-renderer] Loaded font via fc-match: {path}"); + return Ok(data); + } + } + } + log::warn!("[text-renderer] No system font found — text subtitles unavailable"); + Err(()) + }) + .ok() + .map(|v| v.as_slice()) +} + +/// Shared renderer instance (holds nothing except confirmation that a +/// font is loadable). Actual rendering is stateless. +pub struct TextRenderer; + +impl TextRenderer { + /// Returns `None` if no usable font is found on the system. + pub fn new() -> Option { + load_font_data().map(|_| TextRenderer) + } + + /// Render `text` onto a `canvas_w × canvas_h` pre-multiplied ARGB8888 + /// buffer. White text with a dark shadow, centred horizontally, near + /// the bottom of the canvas. Multi-line text (split on `\n`) is + /// supported. + /// + /// Performance: two glyph-rasterisation passes (shadow + foreground), + /// no per-pixel post-processing. Typically <1 ms for a couple of + /// subtitle lines at 1080p. + /// + /// Returns `None` if the font failed to parse or text is empty. + pub fn render(&self, text: &str, canvas_w: usize, canvas_h: usize) -> Option> { + let font_data = load_font_data()?; + let font = FontRef::try_from_slice(font_data).ok()?; + + // Strip common HTML-ish subtitle tags (, , , etc.), then + // decode character references used by text/x-raw/Pango subtitle payloads. + let clean = clean_subtitle_text(text); + let lines: Vec<&str> = clean.lines().filter(|l| !l.trim().is_empty()).collect(); + if lines.is_empty() { + return None; + } + + // Scale: ~4.5% of canvas height, clamped to a sane range. + let px = (canvas_h as f32 * 0.045).clamp(18.0, 80.0); + let scale = PxScale::from(px); + let scaled = font.as_scaled(scale); + + let line_height = scaled.height() + scaled.line_gap(); + let ascent = scaled.ascent(); + + // Shadow offset in pixels (scales with font size). + let shadow_dx = (px / 16.0).max(1.0).round() as i32; + let shadow_dy = shadow_dx; + + // ── Layout all lines ────────────────────────────────────────── + let mut laid_out: Vec> = Vec::with_capacity(lines.len()); + let mut line_widths: Vec = Vec::with_capacity(lines.len()); + + for line in &lines { + let mut glyphs = Vec::new(); + let mut x = 0.0f32; + let mut prev: Option = None; + for ch in line.chars() { + let gid = font.glyph_id(ch); + if let Some(p) = prev { + x += scaled.kern(p, gid); + } + glyphs.push(gid.with_scale_and_position(scale, point(x, 0.0))); + x += scaled.h_advance(gid); + prev = Some(gid); + } + line_widths.push(x); + laid_out.push(glyphs); + } + + let total_text_h = lines.len() as f32 * line_height; + let margin_bottom = (canvas_h as f32 * 0.06).max(12.0); + let block_top = (canvas_h as f32 - margin_bottom - total_text_h).max(0.0); + + let stride = canvas_w * 4; + let mut argb = vec![0u8; stride * canvas_h]; + + // ── Pass 1: shadow (dark, offset) ───────────────────────────── + self.rasterise_lines( + &font, + &laid_out, + &line_widths, + canvas_w, + canvas_h, + block_top, + ascent, + line_height, + shadow_dx, + shadow_dy, + &mut argb, + |off, cov, buf| { + // Pre-multiplied black at ~70% of glyph coverage. + let a = (cov * 0.7 * 255.0) as u8; + // "max" blend so overlapping shadow glyphs don't darken twice. + // Black premul: B=0 G=0 R=0 A=a + buf[off + 3] = buf[off + 3].max(a); + }, + ); + + // ── Pass 2: foreground (white, no offset) ───────────────────── + self.rasterise_lines( + &font, + &laid_out, + &line_widths, + canvas_w, + canvas_h, + block_top, + ascent, + line_height, + 0, + 0, + &mut argb, + |off, cov, buf| { + // Pre-multiplied white "over" whatever is already there. + let fa = (cov * 255.0) as u8; + if fa == 0 { + return; + } + let inv = 255u16 - fa as u16; + // ARGB8888 little-endian: [B, G, R, A] + buf[off] = (fa as u16 + (buf[off] as u16 * inv / 255)) as u8; + buf[off + 1] = (fa as u16 + (buf[off + 1] as u16 * inv / 255)) as u8; + buf[off + 2] = (fa as u16 + (buf[off + 2] as u16 * inv / 255)) as u8; + buf[off + 3] = (fa as u16 + (buf[off + 3] as u16 * inv / 255)) as u8; + }, + ); + + Some(argb) + } + + /// Rasterise laid-out glyph lines into `buf` via a caller-supplied + /// pixel callback `emit(byte_offset, coverage_0_to_1, buf)`. + #[allow(clippy::too_many_arguments)] + fn rasterise_lines( + &self, + font: &FontRef<'_>, + laid_out: &[Vec], + line_widths: &[f32], + canvas_w: usize, + canvas_h: usize, + block_top: f32, + ascent: f32, + line_height: f32, + dx: i32, + dy: i32, + buf: &mut [u8], + emit: impl Fn(usize, f32, &mut [u8]), + ) { + let stride = canvas_w * 4; + for (i, glyphs) in laid_out.iter().enumerate() { + let lw = line_widths[i]; + let x_off = ((canvas_w as f32 - lw) / 2.0).max(0.0); + let y_off = block_top + i as f32 * line_height + ascent; + + for glyph in glyphs { + let positioned = Glyph { + position: point( + glyph.position.x + x_off + dx as f32, + glyph.position.y + y_off + dy as f32, + ), + ..glyph.clone() + }; + if let Some(og) = font.outline_glyph(positioned) { + let bb = og.px_bounds(); + og.draw(|rx, ry, cov| { + let px = bb.min.x as i32 + rx as i32; + let py = bb.min.y as i32 + ry as i32; + if px >= 0 + && (px as usize) < canvas_w + && py >= 0 + && (py as usize) < canvas_h + { + let off = py as usize * stride + px as usize * 4; + emit(off, cov, buf); + } + }); + } + } + } + } +} + +/// Normalize subtitle text before layout. +fn clean_subtitle_text(input: &str) -> String { + // Strip tags before decoding entities so escaped literal angle brackets + // (e.g. `<not a tag>`) survive as visible text. + decode_subtitle_entities(&strip_tags(input)) +} + +/// Strip HTML-like tags that SRT/WebVTT subtitle text may contain. +fn strip_tags(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut in_tag = false; + for ch in input.chars() { + if ch == '<' { + in_tag = true; + } else if ch == '>' { + in_tag = false; + } else if !in_tag { + out.push(ch); + } + } + out +} + +/// Decode XML/HTML character references in subtitle text. +/// +/// GStreamer's `text/x-raw` subtitle path can carry Pango/XML-ish markup where +/// punctuation and symbols are represented as character references (for example +/// `'`, `’`, `'`, or `’`). Use quick-xml's HTML5 entity table +/// for named references, plus generic decimal/hex numeric references so the full +/// Unicode range can be represented without maintaining a hand-written list. +/// Unknown or malformed references are preserved verbatim instead of causing the +/// whole subtitle line to remain escaped. +fn decode_subtitle_entities(input: &str) -> String { + if !input.as_bytes().contains(&b'&') { + return input.to_owned(); + } + + let mut out = String::with_capacity(input.len()); + let mut pos = 0; + while let Some(relative_start) = input[pos..].find('&') { + let start = pos + relative_start; + out.push_str(&input[pos..start]); + + let entity_start = start + 1; + let Some(relative_end) = input[entity_start..].find(';') else { + out.push_str(&input[start..]); + return out; + }; + let end = entity_start + relative_end; + let entity = &input[entity_start..end]; + + if push_decoded_subtitle_entity(entity, &mut out) { + pos = end + 1; + } else { + // Preserve the ampersand and continue scanning after it. This keeps + // unknown entities intact while still allowing a later valid entity + // on the same line to decode (e.g. `AT&T's`). + out.push('&'); + pos = entity_start; + } + } + + out.push_str(&input[pos..]); + out +} + +fn push_decoded_subtitle_entity(entity: &str, out: &mut String) -> bool { + if let Some(ch) = decode_numeric_subtitle_entity(entity) { + out.push(ch); + true + } else if let Some(value) = quick_xml::escape::resolve_predefined_entity(entity) { + out.push_str(value); + true + } else { + false + } +} + +fn decode_numeric_subtitle_entity(entity: &str) -> Option { + let number = entity.strip_prefix('#')?; + let (digits, radix) = number + .strip_prefix('x') + .or_else(|| number.strip_prefix('X')) + .map_or((number, 10), |hex| (hex, 16)); + + if digits.is_empty() || matches!(digits.as_bytes().first(), Some(b'+' | b'-')) { + return None; + } + + let codepoint = u32::from_str_radix(digits, radix).ok()?; + (codepoint != 0) + .then_some(codepoint) + .and_then(char::from_u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_named_and_numeric_subtitle_entities() { + assert_eq!( + clean_subtitle_text( + "It's "fine" & dandy '’… ’" + ), + "It's \"fine\" & dandy '’… ’" + ); + } + + #[test] + fn preserves_unknown_or_malformed_entities_while_decoding_later_valid_ones() { + assert_eq!( + clean_subtitle_text("AT&T's &madeup; &unfinished"), + "AT&T's &madeup; &unfinished" + ); + } + + #[test] + fn strips_tags_before_decoding_escaped_angle_brackets() { + assert_eq!( + clean_subtitle_text("<literal> 'quoted'"), + " 'quoted'" + ); + } +} diff --git a/subwave_wayland/src/video.rs b/subwave_wayland/src/video.rs index e5d1fb7..bbe822c 100644 --- a/subwave_wayland/src/video.rs +++ b/subwave_wayland/src/video.rs @@ -1,11 +1,16 @@ use crate::internal::Internal; use crate::{ - pipeline::SubsurfacePipeline, subsurface_manager::WaylandSubsurfaceManager, Error, - WaylandIntegration, + pipeline::SubsurfacePipeline, + subsurface_manager::WaylandSubsurfaceManager, + subtitle_runtime::{ + compose_pgs_bitmap, ActiveSubtitleSelection, SubtitleProbeEvent, WaylandSubtitleAction, + WaylandSubtitlePayload, WaylandSubtitleScheduler, + }, + Error, WaylandIntegration, }; use gstreamer as gst; use gstreamer::prelude::*; -use parking_lot::RwLock; +use parking_lot::{Mutex as ParkMutex, RwLock}; use std::result::Result; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc}; @@ -38,10 +43,15 @@ impl Video for SubsurfaceVideo { bus_thread: None, bus_stop: Arc::new(AtomicBool::new(false)), cmd_rx: None, + startup_async_done: false, stream_collection: None, available_subtitles: Vec::new(), current_subtitle_track: None, subtitles_enabled: false, + pgs_stream_ids: Vec::new(), + active_subtitle_selection: Arc::new(ParkMutex::new(ActiveSubtitleSelection::default())), + subtitle_event_rx: None, + subtitle_scheduler: None, available_audio_tracks: Vec::new(), current_audio_track: -1, audio_index_to_stream_id: Vec::new(), @@ -134,6 +144,7 @@ impl Video for SubsurfaceVideo { .map_err(|_| subwave_core::Error::InvalidState)?; p.play().map_err(|_| subwave_core::Error::InvalidState)?; let mut w = self.0.write(); + invalidate_subtitle_state(&mut w); w.is_eos = false; w.restart_stream = false; Ok(()) @@ -170,11 +181,9 @@ impl Video for SubsurfaceVideo { } fn set_speed(&mut self, speed: f64) -> Result<(), subwave_core::Error> { - // Update and apply via seek-rate - { - let mut w = self.0.write(); - w.speed = speed; - } + // Update and apply via a flushing seek-rate request. The resulting GStreamer flush events + // invalidate subtitle state so queued cues are rebuilt for the new playback segment. + self.0.write().speed = speed; if let Some(p) = self.0.read().pipeline.clone() { p.set_playback_rate(speed) .map_err(|_| subwave_core::Error::InvalidState) @@ -235,6 +244,7 @@ impl Video for SubsurfaceVideo { let _ = p.pipeline.set_state(gst::State::Ready); p.pipeline.set_property("suburi", url.as_str()); let _ = p.pipeline.set_state(gst::State::Playing); + invalidate_subtitle_state(&mut self.0.write()); Ok(()) } else { Ok(()) @@ -325,11 +335,16 @@ impl SubsurfaceVideo { bus_thread: None, bus_stop: Arc::new(AtomicBool::new(false)), cmd_rx: None, + startup_async_done: false, stream_collection: None, // Subtitle tracking available_subtitles: Vec::new(), current_subtitle_track: None, subtitles_enabled: false, + pgs_stream_ids: Vec::new(), + active_subtitle_selection: Arc::new(ParkMutex::new(ActiveSubtitleSelection::default())), + subtitle_event_rx: None, + subtitle_scheduler: None, // Audio track tracking available_audio_tracks: Vec::new(), current_audio_track: -1, @@ -380,11 +395,20 @@ impl SubsurfaceVideo { ) -> Result<(), Error> { // Construct subsurface and pipeline (no lock held during external calls) let subsurface = WaylandSubsurfaceManager::new(integration.clone())?; + let compositor_has_cm = subsurface.has_color_management(); + let (uri, active_subtitle_selection) = { + let state = self.0.read(); + (state.uri.clone(), state.active_subtitle_selection.clone()) + }; + let (subtitle_tx, subtitle_rx) = mpsc::channel::(); let pipeline = Arc::new(SubsurfacePipeline::new( - &self.0.read().uri, + &uri, &subsurface, &integration, bounds, + compositor_has_cm, + &active_subtitle_selection, + subtitle_tx, )?); // Apply any pending HTTP headers context before starting message processing @@ -403,11 +427,6 @@ impl SubsurfaceVideo { .name(format!("gst-bus-{}", self.0.read().uri)) .spawn(move || { use gst::MessageView; - // Track desired selection and readiness - let mut desired_select_ids: Option> = None; - let mut did_send_select = false; - let mut pipeline_ready = false; - // Helper to send SelectStreams preferring pipeline fn send_select_streams_preferring_pipeline( pipe: &gst::Pipeline, @@ -427,6 +446,7 @@ impl SubsurfaceVideo { // Mark EOS and schedule restart on UI thread if looping let _ = tx.send(Box::new(|s: &mut Internal| { s.is_eos = true; + invalidate_subtitle_state(s); if s.looping { s.restart_stream = true; } @@ -492,8 +512,7 @@ impl SubsurfaceVideo { let mut audio_ids: Vec = Vec::new(); let mut subtitle_ids: Vec = Vec::new(); let mut first_video_id: Option = None; - let mut best_text_id: Option = None; // text/x-raw preferred - let mut any_text_id: Option = None; + let mut pgs_ids: Vec = Vec::new(); for i in 0..n { if let Some(stream) = collection.stream(i as u32) { @@ -555,37 +574,90 @@ impl SubsurfaceVideo { codec = Some(v.get().to_string()); } } + let mut is_text_subtitle = false; + let mut is_bitmap_subtitle = false; if let Some(c) = caps.as_ref().and_then(|c| c.structure(0)) { if codec.is_none() { codec = Some(c.name().to_string()); } - // Remember best raw text - if c.name().starts_with("text/x-raw") && best_text_id.is_none() { - best_text_id = Some(sid.to_string()); - } + let caps_name = c.name().as_str(); + is_text_subtitle = caps_name.starts_with("text/"); + is_bitmap_subtitle = caps_name == "subpicture/x-pgs" + || caps_name == "subpicture/x-dvd"; + } + + if is_bitmap_subtitle { + pgs_ids.push(sid.to_string()); } - if any_text_id.is_none() { any_text_id = Some(sid.to_string()); } - let idx = subtitle_tracks.len() as i32; - subtitle_tracks.push(SubtitleTrack { index: idx, language, title, codec }); - subtitle_ids.push(sid.to_string()); + if is_text_subtitle || is_bitmap_subtitle { + let idx = subtitle_tracks.len() as i32; + subtitle_tracks.push(SubtitleTrack { index: idx, language, title, codec }); + subtitle_ids.push(sid.to_string()); + } else { + log::info!( + "[streams] Skipping unsupported subtitle format {sid}: codec={codec:?}" + ); + } } } } - // Compute initial selection - let mut initial_ids: Vec = Vec::new(); - if let Some(v) = first_video_id.clone() { initial_ids.push(v); } - if let Some(aid) = audio_ids.first() { initial_ids.push(aid.clone()); } - let chosen_text = best_text_id.or(any_text_id); - if let Some(tid) = chosen_text.clone() { initial_ids.push(tid); } + // Track current playbin selection without forcing extra + // reconfigure churn. Keep this aligned with feat/subs: use + // playbin's current-audio property when it is already set, and + // only stabilize it to 0 if playbin has not selected audio yet. + let mut current_audio_prop = if gst_pipeline.has_property("current-audio") { + gst_pipeline.property::("current-audio") + } else { + -1 + }; + if current_audio_prop < 0 + && !audio_ids.is_empty() + && gst_pipeline.has_property("current-audio") + { + gst_pipeline.set_property("current-audio", 0i32); + current_audio_prop = 0; + } + + let mut selected_ids: Vec = Vec::new(); + if let Some(v) = first_video_id.clone() { + selected_ids.push(v); + } + + let mut current_audio_index = -1; + if current_audio_prop >= 0 + && (current_audio_prop as usize) < audio_ids.len() + { + current_audio_index = current_audio_prop; + selected_ids.push(audio_ids[current_audio_prop as usize].clone()); + } else if let Some(aid) = audio_ids.first() { + current_audio_index = 0; + selected_ids.push(aid.clone()); + } - let subtitles_enabled = chosen_text.is_some(); - let current_audio_index = if audio_ids.is_empty() { -1 } else { 0 }; - let current_sub_index = if subtitles_enabled { Some(0) } else { None }; + // Subtitles start disabled; scheduler/probes handle them out-of-band. + let subtitles_enabled = false; + let current_sub_index = None; + + dedup_in_place(&mut selected_ids); + + // Send SelectStreams to playbin3 so audio is included in the + // active selection. Subtitle IDs are deliberately omitted so + // playbin3 never activates subtitleoverlay. + if !selected_ids.is_empty() { + if send_select_streams_preferring_pipeline(&gst_pipeline, &selected_ids) { + log::info!( + "[streams] Sent SelectStreams with {} ids", + selected_ids.len() + ); + } else { + log::warn!("[streams] Failed to send SelectStreams event"); + } + } // Update internal state immediately to expose available tracks let coll_clone = collection.clone(); let tx_tracks = tx.clone(); - let ids_for_state = initial_ids.clone(); + let ids_for_state = selected_ids.clone(); if tx_tracks .send(Box::new(move |s: &mut Internal| { s.stream_collection = Some(coll_clone); @@ -593,10 +665,16 @@ impl SubsurfaceVideo { s.available_subtitles = subtitle_tracks; s.audio_index_to_stream_id = audio_ids; s.subtitle_index_to_stream_id = subtitle_ids; + s.pgs_stream_ids = pgs_ids; s.selected_stream_ids = ids_for_state; s.current_audio_track = current_audio_index; s.current_subtitle_track = current_sub_index; s.subtitles_enabled = subtitles_enabled; + s.subtitle_scheduler = None; + s.active_subtitle_selection.lock().set_stream(None); + if let Some(subsurface) = s.subsurface.as_ref() { + let _ = subsurface.clear_subtitle(); + } })) .is_err() { @@ -604,18 +682,6 @@ impl SubsurfaceVideo { break; } - // Stage selection; send when ready - desired_select_ids = Some(initial_ids); - if pipeline_ready && !did_send_select { - if let Some(ref ids) = desired_select_ids { - if send_select_streams_preferring_pipeline(&gst_pipeline, ids) { - log::info!("[streams] Sent SelectStreams after collection"); - did_send_select = true; - } else { - log::warn!("[streams] Failed to send SelectStreams on collection; will retry later"); - } - } - } } MessageView::StreamsSelected(sel) => { let collection = sel.stream_collection(); @@ -629,73 +695,155 @@ impl SubsurfaceVideo { } } } - MessageView::StateChanged(state_changed) => { - if let Some(src) = msg.src() { - if src.name() == gst_pipeline.name() { - let cur = state_changed.current(); - if cur == gst::State::Paused || cur == gst::State::Playing { - pipeline_ready = true; - if !did_send_select { - if let Some(ref ids) = desired_select_ids { - if send_select_streams_preferring_pipeline(&gst_pipeline, ids) { - log::info!("[streams] Sent SelectStreams on state ready"); - did_send_select = true; - } else { - log::warn!("[streams] SelectStreams send failed on state change; will retry"); + MessageView::StateChanged(_state_changed) => {} + MessageView::AsyncDone(_) => { + // ── Detect HDR and update color management ── + // After a state transition completes (PAUSED→PLAYING, + // or after a seek) the caps are settled. Query vsink + // for colorimetry and notify the subsurface manager. + if let Some(vsink) = gst_pipeline.by_name("vsink") { + if let Some(pad) = vsink.static_pad("sink") { + if let Some(caps) = pad.current_caps() { + if let Some(s) = caps.structure(0) { + let colorimetry = s + .get::("colorimetry") + .unwrap_or_default(); + let pixel_format = s + .get::("format") + .or_else(|_| s.get::("drm-format")) + .unwrap_or_default(); + if !colorimetry.is_empty() { + // Extract mastering display and content light level + let mastering = s + .get::("mastering-display-info") + .ok(); + let cll = s.get::("content-light-level").ok(); + + let mut meta = crate::color_management::HdrMetadata { + mastering_primaries: None, + mastering_luminance_min: None, + mastering_luminance_max: None, + max_cll: None, + max_fall: None, + }; + if let Some(ref m) = mastering { + if let Some((prims, max_lum, min_lum)) = + crate::color_management::HdrMetadata::parse_mastering_display(m) + { + meta.mastering_primaries = Some(prims); + meta.mastering_luminance_max = Some(max_lum); + meta.mastering_luminance_min = Some(min_lum); + } + } + if let Some(ref c) = cll { + if let Some((max_cll, max_fall)) = + crate::color_management::HdrMetadata::parse_content_light_level(c) + { + meta.max_cll = Some(max_cll); + meta.max_fall = Some(max_fall); + } } + + // Only tag as HDR if the pixel format can actually + // carry HDR data. If vapostproc already tone-mapped + // to BGRx/8-bit, the pixels are SDR even if + // colorimetry metadata says PQ. + let format_ok = crate::color_management::HdrMetadata::is_hdr_capable_format(&pixel_format); + + let tx_cm = tx.clone(); + let colorimetry_owned = colorimetry.clone(); + let pixel_fmt_owned = pixel_format.clone(); + let _ = tx_cm.send(Box::new(move |state: &mut Internal| { + if let Some(ref subs) = state.subsurface { + if format_ok { + subs.notify_video_colorimetry( + &colorimetry_owned, + Some(&meta), + ); + } else { + log::info!( + "[color-mgmt] Pixel format {pixel_fmt_owned} is SDR; \ + NOT tagging surface as HDR despite PQ colorimetry" + ); + subs.notify_video_colorimetry( + "sdr-override", + None, + ); + } + } + })); + + log::info!( + "[color-mgmt] Detected colorimetry={colorimetry} format={pixel_format} hdr_capable={format_ok} mastering={mastering:?} cll={cll:?}" + ); } } } - /* - // Update seekable - let seekable = { - let mut q = gst::query::Seeking::new(gst::Format::Time); - if gst_pipeline.query(q.query_mut()) { - let (seekable, _, _) = q.result(); - Some(seekable) - } else { None } - }; - if let Some(seek) = seekable { - if tx.send(Box::new(move |s: &mut Internal| s.video_props.seekable = seek)).is_err() { - log::debug!("[bus] receiver dropped; exiting bus thread"); - break; - } - }*/ - } - } - } - MessageView::AsyncDone(_) => { - if pipeline_ready && !did_send_select { - if let Some(ref ids) = desired_select_ids { - if send_select_streams_preferring_pipeline(&gst_pipeline, ids) { - log::info!("[streams] Sent SelectStreams after AsyncDone"); - did_send_select = true; - } } } - // If we are gating autoplay until seek completes, start playback now + + // If we are gating autoplay until seek completes, start playback now. + // A flushing seek discards queued events, including SelectStreams; + // re-send the current video/audio selection before resuming. let tx_play = tx.clone(); let pipeline_clone = gst_pipeline.clone(); let _ = tx_play.send(Box::new(move |state: &mut Internal| { + state.startup_async_done = true; + + if !state.selected_stream_ids.is_empty() { + if let Some(p) = state.pipeline.as_ref() { + let ids = state.selected_stream_ids.clone(); + if p.send_select_streams(&ids) { + log::info!( + "[streams] Re-sent SelectStreams ({} ids) after AsyncDone", + ids.len() + ); + } else { + log::warn!( + "[streams] Failed to re-send SelectStreams after AsyncDone" + ); + } + } + } + if state.pending_play_after_seek { - // Optional check: confirm position advanced near target - if let Some(pos) = pipeline_clone + let position = pipeline_clone .query_position::() - .map(|ct| Duration::from_nanos(ct.nseconds())) + .map(|ct| Duration::from_nanos(ct.nseconds())); + + if let (Some(target), Some(pos)) = + (state.pending_start_position, position) { - let _target = state.pending_start_position; - // We trust ACCURATE seek; no strict gating on delta required - log::debug!("[seek] AsyncDone at {:?}", pos); + let tolerance = Duration::from_secs(2); + if pos.abs_diff(target) > tolerance { + log::debug!( + "[seek] Ignoring AsyncDone at {pos:?}; waiting for resume target {target:?}" + ); + return; + } + log::info!( + "[seek] AsyncDone reached resume target {target:?} at {pos:?}; resuming playback" + ); + } else if let Some(pos) = position { + log::debug!("[seek] AsyncDone at {pos:?}"); } - // Only auto-play if user hasn't requested pause + + // Only auto-play if user hasn't requested pause. if !state.user_paused { if let Some(p) = state.pipeline.clone() { - let _ = p.play(); + if let Err(err) = p.play() { + log::warn!( + "[seek] Failed to resume playback after seek: {err}" + ); + } } } else { - log::debug!("Autoplay gated by user pause; remaining paused"); + log::debug!( + "Autoplay gated by user pause; remaining paused" + ); } state.pending_play_after_seek = false; + state.pending_start_position = None; } })); //// Refresh seekable on AsyncDone as well @@ -730,6 +878,7 @@ impl SubsurfaceVideo { w.subsurface = Some(subsurface); w.pipeline = Some(pipeline); w.cmd_rx = Some(rx); + w.subtitle_event_rx = Some(subtitle_rx); } Ok(()) @@ -737,8 +886,8 @@ impl SubsurfaceVideo { // Drain pending bus commands and pump subtitles. Intended to be called on UI/redraw ticks. pub fn tick(&mut self) { - // 1) Apply pending commands with a short write lock - let pending = { + // 1) Apply pending commands and collect subtitle work with a short write lock. + let (pending, subtitle_actions) = { let mut w = self.0.write(); loop { let cmd_opt = { @@ -753,9 +902,13 @@ impl SubsurfaceVideo { None => break, } } + + drain_subtitle_probe_events(&mut w); + // Handle scheduled restart on UI thread if w.restart_stream { if let Some(p) = w.pipeline.clone() { + invalidate_subtitle_state(&mut w); if p.seek(Position::Time(Duration::ZERO), true).is_ok() { let _ = p.play(); w.is_eos = false; @@ -763,15 +916,28 @@ impl SubsurfaceVideo { } } } + + let subtitle_actions = drain_due_subtitle_actions(&mut w); // Take any pending state to apply outside the lock - w.pending_state.take() + (w.pending_state.take(), subtitle_actions) }; - // 2) Apply pending state when pipeline is ready + // 2) Apply subtitle actions that became due while processing this tick. + // Pending playback state may seek or switch tracks; apply it afterwards so + // its invalidation can clear anything made visible on this tick. + self.apply_subtitle_actions(subtitle_actions); + + // 3) Apply pending state only after the first AsyncDone. Applying + // resume-state seeks/track selections during NULL→READY/PAUSED can + // re-enter playbin3 while subtitle pads are still being exposed and + // stall startup on files with many subtitle streams. if let Some(st) = pending { - let has_pipeline = self.0.read().pipeline.is_some(); - if has_pipeline { - // Best-effort apply; if not ready, requeue + let ready_for_apply = { + let r = self.0.read(); + r.pipeline.is_some() && r.startup_async_done + }; + + if ready_for_apply { let requeue = self.apply_state_now(&st).is_err(); if requeue { let mut w = self.0.write(); @@ -782,8 +948,73 @@ impl SubsurfaceVideo { w.pending_state = Some(st); } } + } + + fn apply_subtitle_actions(&self, actions: Vec) { + if actions.is_empty() { + return; + } + + let subsurface = self.0.read().subsurface.clone(); + let Some(subsurface) = subsurface else { + return; + }; - // 3) (Optional) subtitle draining could happen here + for action in actions { + match action { + crate::subtitle_scheduler::SubtitleAction::Clear(_) => { + let _ = subsurface.clear_subtitle(); + } + crate::subtitle_scheduler::SubtitleAction::Attach(attach) => match attach.payload { + WaylandSubtitlePayload::Pgs { + frames, + video_width, + video_height, + } => { + let (surface_width, surface_height) = subsurface.get_size(); + if let Some(bitmap) = compose_pgs_bitmap( + &frames, + video_width, + video_height, + surface_width, + surface_height, + ) { + let _ = subsurface.attach_subtitle_frame( + &bitmap.data, + bitmap.width, + bitmap.height, + bitmap.stride, + ); + } else { + let _ = subsurface.clear_subtitle(); + } + } + WaylandSubtitlePayload::Text(text) => { + let (width, height) = subsurface.get_size(); + let width = width.max(1) as usize; + let height = height.max(1) as usize; + match crate::text_renderer::TextRenderer::new() + .and_then(|renderer| renderer.render(&text, width, height)) + { + Some(argb) => { + let _ = subsurface.attach_subtitle_frame( + &argb, + width as i32, + height as i32, + (width * 4) as i32, + ); + } + None => { + log::warn!( + "[text-probe] No font available or text empty; clearing subtitle" + ); + let _ = subsurface.clear_subtitle(); + } + } + } + }, + } + } } // Control @@ -851,29 +1082,46 @@ impl SubsurfaceVideo { fn apply_state_now(&mut self, st: &PendingState) -> Result<(), ()> { // Pause first, ignore errors let _ = self.pause(); - let _ = self.select_audio_track(st.audio_track); - let _ = self.select_subtitle_track(st.subtitle_track); - self.set_subtitles_enabled(st.subtitles_enabled); + + // Only apply explicit track selections when present to avoid startup churn. + if st.audio_track >= 0 { + let _ = self.select_audio_track(st.audio_track); + } + + if st.subtitles_enabled { + if st.subtitle_track.is_some() { + let _ = self.select_subtitle_track(st.subtitle_track); + } else { + self.set_subtitles_enabled(true); + } + } else if self.subtitles_enabled() { + let _ = self.select_subtitle_track(None); + } + if let Some(url) = &st.subtitle_url { let _ = self.set_subtitle_url(url); } + { + let mut w = self.0.write(); + w.pending_start_position = Some(st.position); + w.pending_play_after_seek = false; + } if self.seek(st.position, true).is_err() { return Err(()); } self.set_volume(st.volume); self.set_muted(st.muted); let _ = self.set_playback_rate(st.speed); - // If autoplay-after-seek gating is enabled, do not start playback here; wait for AsyncDone - let gate = { self.0.read().pending_play_after_seek }; - if gate { - let _ = self.pause(); - } else { - if st.paused { - let _ = self.pause(); - } else { - let _ = self.play(); - } + + // Always gate play() behind AsyncDone after the flushing seek above. + // Seek flushes queued SelectStreams events; the AsyncDone handler re-sends + // the current video/audio selection before resuming playback. + if !st.paused { + let mut w = self.0.write(); + w.pending_play_after_seek = true; + w.user_paused = false; } + let _ = self.pause(); Ok(()) } @@ -882,10 +1130,11 @@ impl SubsurfaceVideo { w.pending_state = Some(st); } - /// Enable autoplay after the next seek completes (AsyncDone), starting exactly at position. + /// Record the resume target. The autoplay gate is armed after the actual + /// pending-state seek is issued, so an initial startup AsyncDone cannot + /// accidentally consume it before the resume seek runs. pub fn enable_autoplay_after_seek(&mut self, position: Duration) { let mut w = self.0.write(); - w.pending_play_after_seek = true; w.pending_start_position = Some(position); } @@ -984,7 +1233,8 @@ impl SubsurfaceVideo { pub fn set_playback_rate(&self, rate: f64) -> Result<(), Error> { if let Some(p) = self.0.read().pipeline.clone() { - p.set_playback_rate(rate) + p.set_playback_rate(rate)?; + Ok(()) } else { Ok(()) } @@ -1039,6 +1289,13 @@ impl SubsurfaceVideo { // Dedup while preserving order dedup_in_place(&mut new_ids); + // No-op: desired selection already active. + if new_ids == self.0.read().selected_stream_ids { + let mut w = self.0.write(); + w.current_audio_track = index; + return Ok(()); + } + let ok = p.send_select_streams(&new_ids); if ok { let mut w = self.0.write(); @@ -1053,48 +1310,94 @@ impl SubsurfaceVideo { } pub fn select_subtitle_track(&self, index: Option) -> Result<(), Error> { - let (p, mut new_ids, sub_ids) = { + let (sub_ids, pgs_ids, subsurface) = { let r = self.0.read(); - let p = r.pipeline.clone(); - let mut ids = r.selected_stream_ids.clone(); - // Remove existing subtitle ids - if !r.subtitle_index_to_stream_id.is_empty() { - ids.retain(|id| !r.subtitle_index_to_stream_id.iter().any(|sid| sid == id)); + if r.pipeline.is_none() { + return Err(Error::Pipeline("Video not initialized".into())); } - (p, ids, r.subtitle_index_to_stream_id.clone()) - }; - let Some(p) = p else { - return Err(Error::Pipeline("Video not initialized".into())); + if let Some(i) = index { + if i < 0 || (i as usize) >= r.subtitle_index_to_stream_id.len() { + return Err(Error::Pipeline(format!( + "Invalid subtitle track index: {}", + i + ))); + } + } + + ( + r.subtitle_index_to_stream_id.clone(), + r.pgs_stream_ids.clone(), + r.subsurface.clone(), + ) }; - let mut new_current: Option = None; - let mut enabled = false; - if let Some(i) = index { - if i < 0 || (i as usize) >= sub_ids.len() { - return Err(Error::Pipeline(format!( - "Invalid subtitle track index: {}", - i - ))); + match index { + Some(i) => { + let stream_id = sub_ids[i as usize].clone(); + let is_pgs = pgs_ids.contains(&stream_id); + + { + let mut w = self.0.write(); + // Subtitle rendering is fully out-of-band via demuxer pad probes. + // Never send subtitle stream IDs through playbin3 SelectStreams: + // doing so activates playbin3's subtitle path / reconfigure logic and + // can stall startup on files with many subtitle streams. + w.selected_stream_ids = selected_stream_ids_without_subtitles( + &w.selected_stream_ids, + &w.subtitle_index_to_stream_id, + ); + + let generation = { + let mut active = w.active_subtitle_selection.lock(); + active.set_stream(Some(stream_id.clone())) + }; + if let Some(scheduler) = w.subtitle_scheduler.as_mut() { + let _ = scheduler.switch_stream(stream_id.clone(), generation); + } else { + w.subtitle_scheduler = + Some(WaylandSubtitleScheduler::new(stream_id.clone(), generation)); + } + w.current_subtitle_track = Some(i); + w.subtitles_enabled = true; + } + + if let Some(subsurface) = subsurface.as_ref() { + let _ = subsurface.clear_subtitle(); + } + log::info!( + "[subs] Selected out-of-band subtitle index={i}, stream={stream_id}, pgs={is_pgs}" + ); + Ok(()) } - let sid = sub_ids[i as usize].clone(); - new_ids.push(sid); - new_current = Some(i); - enabled = true; - } - dedup_in_place(&mut new_ids); + None => { + let should_log = { + let mut w = self.0.write(); + w.selected_stream_ids = selected_stream_ids_without_subtitles( + &w.selected_stream_ids, + &w.subtitle_index_to_stream_id, + ); + let was_enabled = w.subtitles_enabled || w.current_subtitle_track.is_some(); + { + let mut active = w.active_subtitle_selection.lock(); + if active.stream_id.is_some() { + active.set_stream(None); + } + } + w.subtitle_scheduler = None; + w.current_subtitle_track = None; + w.subtitles_enabled = false; + was_enabled + }; - let ok = p.send_select_streams(&new_ids); - if ok { - let mut w = self.0.write(); - w.selected_stream_ids = new_ids; - w.current_subtitle_track = new_current; - w.subtitles_enabled = enabled; - Ok(()) - } else { - Err(Error::Pipeline( - "Failed to send SelectStreams for subtitles".into(), - )) + if let Some(subsurface) = subsurface.as_ref() { + let _ = subsurface.clear_subtitle(); + } + if should_log { + log::info!("[subs] Disabled out-of-band subtitles"); + } + Ok(()) + } } } @@ -1146,6 +1449,82 @@ impl SubsurfaceVideo { } } +fn drain_subtitle_probe_events(state: &mut Internal) { + loop { + let event = match state.subtitle_event_rx.as_ref() { + Some(rx) => rx.try_recv().ok(), + None => None, + }; + + let Some(event) = event else { + break; + }; + + match event { + SubtitleProbeEvent::Decoded(event) => { + if let Some(scheduler) = state.subtitle_scheduler.as_mut() { + scheduler.push_event(event); + } + } + SubtitleProbeEvent::Invalidate { + stream_id, + generation, + } => { + let is_current = { + let active = state.active_subtitle_selection.lock(); + active.stream_id.as_deref() == Some(stream_id.as_str()) + && active.generation == generation + }; + if is_current { + invalidate_subtitle_state(state); + } + } + } + } +} + +fn drain_due_subtitle_actions(state: &mut Internal) -> Vec { + let running_time = state + .pipeline + .as_ref() + .and_then(|pipeline| pipeline.pipeline.query_position::()) + .map(|position| Duration::from_nanos(position.nseconds())); + + match (state.subtitle_scheduler.as_mut(), running_time) { + (Some(scheduler), Some(running_time)) => scheduler.drain_due(running_time), + _ => Vec::new(), + } +} + +fn invalidate_subtitle_state(state: &mut Internal) { + let generation = { + let mut active = state.active_subtitle_selection.lock(); + active.flush() + }; + + if let Some(scheduler) = state.subtitle_scheduler.as_mut() { + let _ = scheduler.flush_generation(generation); + } + + if let Some(subsurface) = state.subsurface.as_ref() { + let _ = subsurface.clear_subtitle(); + } +} + +fn selected_stream_ids_without_subtitles( + selected_stream_ids: &[String], + subtitle_stream_ids: &[String], +) -> Vec { + let mut ids = selected_stream_ids.to_vec(); + ids.retain(|id| { + !subtitle_stream_ids + .iter() + .any(|subtitle_id| subtitle_id == id) + }); + dedup_in_place(&mut ids); + ids +} + fn dedup_in_place(v: &mut Vec) { let mut seen = std::collections::HashSet::::new(); v.retain(|s| seen.insert(s.clone())); @@ -1167,3 +1546,34 @@ impl Drop for SubsurfaceVideo { } } } + +#[cfg(test)] +mod tests { + use super::selected_stream_ids_without_subtitles; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + #[test] + fn out_of_band_subtitle_selection_keeps_subtitle_ids_out_of_select_streams() { + let selected = strings(&["video/0", "audio/0", "subtitle/en"]); + let subtitle_ids = strings(&["subtitle/en", "subtitle/es"]); + + assert_eq!( + selected_stream_ids_without_subtitles(&selected, &subtitle_ids), + strings(&["video/0", "audio/0"]) + ); + } + + #[test] + fn subtitle_id_filter_preserves_video_audio_order_and_deduplicates() { + let selected = strings(&["video/0", "audio/0", "subtitle/en", "audio/0"]); + let subtitle_ids = strings(&["subtitle/en", "subtitle/es"]); + + assert_eq!( + selected_stream_ids_without_subtitles(&selected, &subtitle_ids), + strings(&["video/0", "audio/0"]) + ); + } +}