From 6341cffafaeaf3002bf37b6271277a97f82ac7da Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Thu, 18 Jun 2026 14:17:55 -0600 Subject: [PATCH 1/6] fix: avoid startup churn when opening streams Treat a zero start position as no explicit resume target so opening from the beginning does not issue a redundant flushing seek. Also keep HTTP header context values in the string form GStreamer expects. --- subwave_appsink/src/render_pipeline.rs | 6 +----- subwave_core/src/http.rs | 2 +- subwave_unified/src/video.rs | 4 +++- 3 files changed, 5 insertions(+), 7 deletions(-) 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 { From 4c15d37a896b6968955d62dc8af0e42cdcfa8753 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Thu, 18 Jun 2026 14:18:19 -0600 Subject: [PATCH 2/6] feat: add clocked Wayland subtitle rendering primitives Add the media-time subtitle scheduler, PGS decoder, text renderer, and Wayland subtitle runtime payload types used to queue decoded cues before presenting them on UI ticks. --- Cargo.lock | 1374 ++++++++++++--------- subwave_wayland/Cargo.toml | 4 +- subwave_wayland/src/pgs_decoder.rs | 437 +++++++ subwave_wayland/src/subtitle_runtime.rs | 138 +++ subwave_wayland/src/subtitle_scheduler.rs | 651 ++++++++++ subwave_wayland/src/text_renderer.rs | 255 ++++ 6 files changed, 2303 insertions(+), 556 deletions(-) create mode 100644 subwave_wayland/src/pgs_decoder.rs create mode 100644 subwave_wayland/src/subtitle_runtime.rs create mode 100644 subwave_wayland/src/subtitle_scheduler.rs create mode 100644 subwave_wayland/src/text_renderer.rs diff --git a/Cargo.lock b/Cargo.lock index b421730..a5ff162 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 = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +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 = "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", @@ -3172,7 +3363,7 @@ dependencies = [ "rustc-hash 2.1.1", "subwave_core", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "wayland-backend", "wayland-client", @@ -3198,9 +3389,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 +3439,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 +3470,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 +3490,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 +3501,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 +3550,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 +3565,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 +3648,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 +3672,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 +3684,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 +3700,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 +3759,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 +3790,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 +3804,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 +3814,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 +3827,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 +3884,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 +3898,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 +3914,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 +3948,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 +3957,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 +3970,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 +3983,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 +3996,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 +4007,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 +4019,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 +4050,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 +4081,7 @@ dependencies = [ "arrayvec", "bit-set", "bit-vec", - "bitflags 2.10.0", + "bitflags 2.11.0", "bytemuck", "cfg_aliases", "document-features", @@ -3846,7 +4096,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 +4141,7 @@ dependencies = [ "arrayvec", "ash", "bit-set", - "bitflags 2.10.0", + "bitflags 2.11.0", "block", "bytemuck", "cfg-if", @@ -3922,7 +4172,7 @@ dependencies = [ "raw-window-handle", "renderdoc-sys", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "wasm-bindgen", "web-sys", "wgpu-types", @@ -3936,11 +4186,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 +4205,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 +4224,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 +4237,7 @@ dependencies = [ "windows-interface", "windows-result", "windows-strings", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4024,7 +4274,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 +4284,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 +4293,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 +4302,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 +4314,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 +4354,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 +4380,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 +4432,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 +4561,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", "x11rb-protocol", ] @@ -4310,7 +4583,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 +4645,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 +4718,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 +4740,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_wayland/Cargo.toml b/subwave_wayland/Cargo.toml index 8ca052c..b43c97c 100644 --- a/subwave_wayland/Cargo.toml +++ b/subwave_wayland/Cargo.toml @@ -15,6 +15,7 @@ parking_lot = "0.12" once_cell = "^1.19" libc = "0.2.175" tempfile = "3.21.0" +ab_glyph = "0.2" [target.'cfg(target_os = "linux")'.dependencies] # Iced dependencies @@ -24,12 +25,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/pgs_decoder.rs b/subwave_wayland/src/pgs_decoder.rs new file mode 100644 index 0000000..9ab3de2 --- /dev/null +++ b/subwave_wayland/src/pgs_decoder.rs @@ -0,0 +1,437 @@ +//! 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. + +/// 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, +} + +/// Accumulates PGS segments and produces decoded frames. +pub struct PgsDecoder { + palette: Vec<[u8; 4]>, // 256 ARGB entries + objects: Vec, + compositions: Vec, + /// 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 + +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(), + 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 frames when an END segment is encountered. + /// An empty `Vec` means "clear the subtitle" (composition with no objects). + pub fn feed(&mut self, data: &[u8]) -> Option> { + let mut result: Option> = None; + 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. + 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; + } + // 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]; + + 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 => { + log::info!( + "[pgs] END — display set complete ({} objects, {} compositions)", + self.objects.len(), + self.compositions.len() + ); + result = Some(self.finish_display_set()); + } + _ => { + 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; + } + + result + } + + 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.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) -> Vec { + if self.compositions.is_empty() { + // No composition objects = clear subtitle + self.objects.clear(); + return Vec::new(); + } + + 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.compositions.clear(); + frames + } + + 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; + } + } +} 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..6137657 --- /dev/null +++ b/subwave_wayland/src/text_renderer.rs @@ -0,0 +1,255 @@ +//! 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.) + let clean = strip_tags(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); + } + }); + } + } + } + } +} + +/// 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 +} From d31ff194336ec9f626fab32049bf5fdcfec28fe9 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Thu, 18 Jun 2026 14:18:25 -0600 Subject: [PATCH 3/6] feat: tag HDR Wayland video surfaces for compositor tone mapping Bind wp-color-management-v1 when available, tag HDR video surfaces with stream colorimetry, keep subtitle buffers in the compositor default sRGB space, and keep subtitle subsurface commits synchronized with the parent. --- subwave_wayland/src/color_management.rs | 458 ++++++++++++++++++++++ subwave_wayland/src/lib.rs | 9 + subwave_wayland/src/subsurface_manager.rs | 139 ++++++- 3 files changed, 596 insertions(+), 10 deletions(-) create mode 100644 subwave_wayland/src/color_management.rs 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/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/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(); From 8e3c44fbe285f2b244279a09dcac1982f09a13a4 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Thu, 18 Jun 2026 14:17:45 -0600 Subject: [PATCH 4/6] fix: present Wayland subtitles without startup or resume freezes Defer waylandsink display/window setup to the bus sync handler, keep subtitle stream IDs out of playbin3 SelectStreams, schedule subtitle presentation from demuxer probes, and resume only after the target seek AsyncDone is observed. --- subwave_wayland/src/gstplayflags.rs | 11 +- subwave_wayland/src/internal.rs | 15 +- subwave_wayland/src/pipeline.rs | 769 +++++++++++++++++++++++----- subwave_wayland/src/video.rs | 716 ++++++++++++++++++++------ 4 files changed, 1236 insertions(+), 275 deletions(-) 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/pipeline.rs b/subwave_wayland/src/pipeline.rs index 42db348..aa45bcb 100644 --- a/subwave_wayland/src/pipeline.rs +++ b/subwave_wayland/src/pipeline.rs @@ -4,13 +4,88 @@ 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) + } +} + pub struct SubsurfacePipeline { speed: f64, pub pipeline: Arc, @@ -30,6 +105,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 +115,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 +126,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 +205,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 +291,452 @@ 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 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, 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, + }; + if let Some(frames) = decoder.feed(map.as_slice()) { + let event = if frames.is_empty() { + crate::subtitle_scheduler::DecodedSubtitleEvent::clear( + stream_id, generation, start, + ) + } else { + let payload = WaylandSubtitlePayload::Pgs { + frames, + video_width: decoder.video_width, + video_height: decoder.video_height, + }; + crate::subtitle_scheduler::DecodedSubtitleEvent::show( + stream_id, generation, start, end, payload, + ) + }; + 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(gap) => { + if let Some((stream_id, generation)) = Self::active_stream_snapshot( + probe_pad, + &pad_stream_id, + &active, + "pgs-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, + "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 +773,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 +859,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 +917,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); 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"]) + ); + } +} From 24632e03a509c9fc1f44bb3906c003a42fd8bc1b Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Thu, 18 Jun 2026 14:26:38 -0600 Subject: [PATCH 5/6] fix: decode x-raw subtitle entities --- Cargo.lock | 1 + subwave_wayland/Cargo.toml | 1 + subwave_wayland/src/text_renderer.rs | 115 ++++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a5ff162..63e04c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3359,6 +3359,7 @@ dependencies = [ "log", "once_cell", "parking_lot", + "quick-xml", "raw-window-handle", "rustc-hash 2.1.1", "subwave_core", diff --git a/subwave_wayland/Cargo.toml b/subwave_wayland/Cargo.toml index b43c97c..905fbe5 100644 --- a/subwave_wayland/Cargo.toml +++ b/subwave_wayland/Cargo.toml @@ -16,6 +16,7 @@ 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 diff --git a/subwave_wayland/src/text_renderer.rs b/subwave_wayland/src/text_renderer.rs index 6137657..9e854bc 100644 --- a/subwave_wayland/src/text_renderer.rs +++ b/subwave_wayland/src/text_renderer.rs @@ -86,8 +86,9 @@ impl TextRenderer { let font_data = load_font_data()?; let font = FontRef::try_from_slice(font_data).ok()?; - // Strip common HTML-ish subtitle tags (, , , etc.) - let clean = strip_tags(text); + // 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; @@ -238,6 +239,13 @@ impl TextRenderer { } } +/// 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()); @@ -253,3 +261,106 @@ fn strip_tags(input: &str) -> String { } 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'" + ); + } +} From ed937436a729c9b1ae4238a0fe09a4db1fb84c32 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Thu, 18 Jun 2026 15:09:08 -0600 Subject: [PATCH 6/6] fix(subtitles): persist PGS cues until clear display set --- subwave_wayland/src/pgs_decoder.rs | 261 +++++++++++++++++++++++++++-- subwave_wayland/src/pipeline.rs | 215 ++++++++++++++++++++---- 2 files changed, 433 insertions(+), 43 deletions(-) diff --git a/subwave_wayland/src/pgs_decoder.rs b/subwave_wayland/src/pgs_decoder.rs index 9ab3de2..4e3ce65 100644 --- a/subwave_wayland/src/pgs_decoder.rs +++ b/subwave_wayland/src/pgs_decoder.rs @@ -8,6 +8,8 @@ //! 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 { @@ -23,11 +25,32 @@ pub struct PgsFrame { pub y: u16, } -/// Accumulates PGS segments and produces decoded frames. +/// 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, @@ -54,6 +77,10 @@ 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() @@ -66,6 +93,10 @@ impl PgsDecoder { 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, } @@ -78,10 +109,10 @@ impl PgsDecoder { /// [1B type][2B size][...payload...] /// /// This method loops through all segments in the buffer and returns - /// decoded frames when an END segment is encountered. - /// An empty `Vec` means "clear the subtitle" (composition with no objects). - pub fn feed(&mut self, data: &[u8]) -> Option> { - let mut result: Option> = None; + /// 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() { @@ -89,6 +120,7 @@ impl PgsDecoder { // 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; @@ -96,6 +128,13 @@ impl PgsDecoder { 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; } @@ -121,6 +160,12 @@ impl PgsDecoder { 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()); @@ -142,12 +187,18 @@ impl PgsDecoder { self.parse_ods(payload); } SEG_END => { + let pts = self.display_set_pts; log::info!( - "[pgs] END — display set complete ({} objects, {} compositions)", + "[pgs] END — display set complete ({} objects, {} compositions, pcs={}, state=0x{:02x}, pts={:?})", self.objects.len(), - self.compositions.len() + self.compositions.len(), + self.seen_pcs, + self.pcs_composition_state, + pts ); - result = Some(self.finish_display_set()); + if let Some(action) = self.finish_display_set() { + results.push(TimedPgsDisplaySet { action, pts }); + } } _ => { log::debug!( @@ -163,7 +214,7 @@ impl PgsDecoder { offset = payload_end; } - result + results } fn parse_pcs(&mut self, data: &[u8]) { @@ -179,6 +230,9 @@ impl PgsDecoder { // 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 { @@ -300,11 +354,24 @@ impl PgsDecoder { } } - fn finish_display_set(&mut self) -> Vec { + 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() { - // No composition objects = clear subtitle - self.objects.clear(); - return Vec::new(); + self.reset_display_set_state(); + return None; } let mut frames = Vec::new(); @@ -336,8 +403,21 @@ impl PgsDecoder { // 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(); - frames } fn decode_rle(&self, obj: &OdsData) -> Result, String> { @@ -435,3 +515,156 @@ impl PgsDecoder { } } } + +#[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 aa45bcb..40ccc0d 100644 --- a/subwave_wayland/src/pipeline.rs +++ b/subwave_wayland/src/pipeline.rs @@ -86,6 +86,37 @@ impl PadSubtitleTiming { } } +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, @@ -486,6 +517,18 @@ impl SubsurfacePipeline { 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 @@ -535,7 +578,8 @@ impl SubsurfacePipeline { ) else { return gst::PadProbeReturn::Ok; }; - let Some((start, end)) = Self::buffer_window(buffer, &timing) else { + let Some((start, _buffer_end)) = Self::buffer_window(buffer, &timing) + else { log::debug!("[pgs-probe] Buffer without timestamp; skipping"); return gst::PadProbeReturn::Ok; }; @@ -547,21 +591,26 @@ impl SubsurfacePipeline { Ok(decoder) => decoder, Err(_) => return gst::PadProbeReturn::Ok, }; - if let Some(frames) = decoder.feed(map.as_slice()) { - let event = if frames.is_empty() { - crate::subtitle_scheduler::DecodedSubtitleEvent::clear( - stream_id, generation, start, - ) - } else { - let payload = WaylandSubtitlePayload::Pgs { - frames, - video_width: decoder.video_width, - video_height: decoder.video_height, - }; - crate::subtitle_scheduler::DecodedSubtitleEvent::show( - stream_id, generation, start, end, payload, - ) - }; + 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)); } } @@ -585,19 +634,15 @@ impl SubsurfacePipeline { Self::update_timing_from_segment(&timing, segment, "pgs-probe"); } } - gst::EventView::Gap(gap) => { - if let Some((stream_id, generation)) = Self::active_stream_snapshot( - probe_pad, - &pad_stream_id, - &active, - "pgs-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::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( @@ -933,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, + }, + )] + ); + } +}