diff --git a/python/foxglove-sdk/python/foxglove/_foxglove_py/mcap.pyi b/python/foxglove-sdk/python/foxglove/_foxglove_py/mcap.pyi index 86a859553..42955c3fa 100644 --- a/python/foxglove-sdk/python/foxglove/_foxglove_py/mcap.pyi +++ b/python/foxglove-sdk/python/foxglove/_foxglove_py/mcap.pyi @@ -116,10 +116,25 @@ class MCAPWriter: Common uses include storing configuration files, calibration data, or other reference material related to the recording. - :param log_time: Time at which the attachment was logged, in nanoseconds since epoch. - :param create_time: Time at which the attachment data was created, in nanoseconds since epoch. + :param log_time: Time at which the attachment was logged, in nanoseconds since + epoch. + :param create_time: Time at which the attachment data was created, in nanoseconds + since epoch. :param name: Name of the attachment (e.g., "config.json"). :param media_type: MIME type of the attachment (e.g., "application/json"). :param data: Binary content of the attachment. """ ... + + def write_layout(self, layout_name: str, layout: str) -> None: + """ + Write a layout to the MCAP file. + + Layouts are accumulated internally and written as a single metadata + record under the "foxglove.layouts" key when the writer is closed. + This allows multiple layouts to be stored with different names. + + :param layout_name: The name to use as the key in the metadata record. + :param layout: The layout JSON string to store. + """ + ... diff --git a/python/foxglove-sdk/python/foxglove/tests/test_mcap.py b/python/foxglove-sdk/python/foxglove/tests/test_mcap.py index ce7ac008a..ad571a797 100644 --- a/python/foxglove-sdk/python/foxglove/tests/test_mcap.py +++ b/python/foxglove-sdk/python/foxglove/tests/test_mcap.py @@ -4,6 +4,7 @@ import pytest from foxglove import Channel, ChannelDescriptor, Context, open_mcap +from foxglove.layouts import Layout, MarkdownPanel from foxglove.mcap import MCAPWriteOptions chan = Channel("test", schema={"type": "object"}) @@ -223,6 +224,65 @@ def test_write_metadata(tmp_mcap: Path) -> None: _verify_metadata_in_file(tmp_mcap, expected_metadata) +def test_write_layout(tmp_mcap: Path) -> None: + """Test writing multiple layouts to MCAP file.""" + import json + + # Create layouts with different panels + layout1 = Layout( + content=MarkdownPanel( + title="Test Panel 1", + ) + ) + layout2 = Layout( + content=MarkdownPanel( + title="Test Panel 2", + ) + ) + + layout_name1 = "my_first_layout" + layout_name2 = "my_second_layout" + + with open_mcap(tmp_mcap) as writer: + # Pass layout_name first, then layout JSON string + writer.write_layout(layout_name1, layout1.to_json()) + writer.write_layout(layout_name2, layout2.to_json()) + + # Log some messages + for ii in range(5): + chan.log({"foo": ii}) + + # Verify layouts were written correctly as metadata + import mcap.reader + + with open(tmp_mcap, "rb") as f: + reader = mcap.reader.make_reader(f) + + found_layout = False + for record in reader.iter_metadata(): + if record.name == "foxglove.layouts": + found_layout = True + # Both layouts should be in the same metadata record + assert layout_name1 in record.metadata + assert layout_name2 in record.metadata + + # Verify the first layout + layout_json1 = record.metadata[layout_name1] + layout_data1 = json.loads(layout_json1) + assert layout_data1.get("version") == 1 + assert "content" in layout_data1 + assert layout_data1["content"]["panelType"] == "Markdown" + + # Verify the second layout + layout_json2 = record.metadata[layout_name2] + layout_data2 = json.loads(layout_json2) + assert layout_data2.get("version") == 1 + assert "content" in layout_data2 + assert layout_data2["content"]["panelType"] == "Markdown" + + assert found_layout, "Layout metadata not found in MCAP file" + + def test_channel_filter(make_tmp_mcap: Callable[[], Path]) -> None: tmp_1 = make_tmp_mcap() tmp_2 = make_tmp_mcap() diff --git a/python/foxglove-sdk/src/lib.rs b/python/foxglove-sdk/src/lib.rs index bc6ac4352..86b5bdcaf 100644 --- a/python/foxglove-sdk/src/lib.rs +++ b/python/foxglove-sdk/src/lib.rs @@ -252,7 +252,7 @@ fn open_mcap( }; let handle = handle.create(writer).map_err(PyFoxgloveError::from)?; - Ok(PyMcapWriter(Some(handle))) + Ok(PyMcapWriter::new(handle)) } #[pyfunction] diff --git a/python/foxglove-sdk/src/mcap.rs b/python/foxglove-sdk/src/mcap.rs index 078c0ad27..1fb5100ae 100644 --- a/python/foxglove-sdk/src/mcap.rs +++ b/python/foxglove-sdk/src/mcap.rs @@ -209,7 +209,19 @@ impl From for McapWriteOptions { /// If the writer is not closed by the time it is garbage collected, it will be /// closed automatically, and any errors will be logged. #[pyclass(name = "MCAPWriter", module = "foxglove.mcap")] -pub(crate) struct PyMcapWriter(pub(crate) Option>>); +pub(crate) struct PyMcapWriter { + pub(crate) writer: Option>>, + layouts: std::collections::BTreeMap, +} + +impl PyMcapWriter { + pub(crate) fn new(writer: McapWriterHandle>) -> Self { + Self { + writer: Some(writer), + layouts: std::collections::BTreeMap::new(), + } + } +} impl Drop for PyMcapWriter { fn drop(&mut self) { @@ -239,7 +251,14 @@ impl PyMcapWriter { /// You may call this to explicitly close the writer. Note that the writer will be automatically /// closed for you when it is garbage collected, or when exiting the context manager. fn close(&mut self) -> PyResult<()> { - if let Some(writer) = self.0.take() { + if let Some(writer) = self.writer.take() { + // Write all accumulated layouts as a single metadata record + if !self.layouts.is_empty() { + let layouts = std::mem::take(&mut self.layouts); + writer + .write_metadata("foxglove.layouts", layouts) + .map_err(PyFoxgloveError::from)?; + } writer.close().map_err(PyFoxgloveError::from)?; } Ok(()) @@ -254,7 +273,7 @@ impl PyMcapWriter { name: &str, metadata: std::collections::BTreeMap, ) -> PyResult<()> { - if let Some(writer) = &self.0 { + if let Some(writer) = &self.writer { writer .write_metadata(name, metadata) .map_err(PyFoxgloveError::from)?; @@ -284,7 +303,7 @@ impl PyMcapWriter { media_type: String, data: Vec, ) -> PyResult<()> { - if let Some(writer) = &self.0 { + if let Some(writer) = &self.writer { writer .attach(&foxglove::McapAttachment { log_time, @@ -299,6 +318,25 @@ impl PyMcapWriter { } Ok(()) } + + /// Write a layout to the MCAP file. + /// + /// Layouts are accumulated internally and written as a single metadata + /// record under the "foxglove.layouts" key when the writer is closed. + /// This allows multiple layouts to be stored with different names. + /// + /// :param layout_name: The name to use as the key in the metadata record. + /// :param layout: The layout JSON string to store. + fn write_layout(&mut self, layout_name: String, layout: String) -> PyResult<()> { + if self.writer.is_none() { + return Err(PyFoxgloveError::from(foxglove::FoxgloveError::SinkClosed).into()); + } + + // Add to the internal layouts map (will be written on close) + self.layouts.insert(layout_name, layout); + + Ok(()) + } } pub fn register_submodule(parent_module: &Bound<'_, PyModule>) -> PyResult<()> { diff --git a/rust/foxglove/src/mcap_writer.rs b/rust/foxglove/src/mcap_writer.rs index 34d25d2eb..1af1e38b0 100644 --- a/rust/foxglove/src/mcap_writer.rs +++ b/rust/foxglove/src/mcap_writer.rs @@ -1,9 +1,10 @@ //! MCAP writer +use std::collections::BTreeMap; use std::fs::File; use std::io::{BufWriter, Seek}; use std::path::Path; -use std::sync::{Arc, Weak}; +use std::sync::{Arc, Mutex, Weak}; use std::{fmt::Debug, io::Write}; use crate::library_version::get_library_version; @@ -112,6 +113,7 @@ impl McapWriter { Ok(McapWriterHandle { sink, context: Arc::downgrade(&self.context), + layouts: Mutex::new(BTreeMap::new()), }) } @@ -140,10 +142,19 @@ impl McapWriter { /// When this handle is dropped, the writer will unregister from the [`Context`], stop logging /// events, and flush any buffered data to the writer. #[must_use] -#[derive(Debug)] pub struct McapWriterHandle { sink: Arc>, context: Weak, + layouts: Mutex>, +} + +impl Debug for McapWriterHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("McapWriterHandle") + .field("sink", &self.sink) + .field("context", &self.context) + .finish_non_exhaustive() + } } impl McapWriterHandle { @@ -158,6 +169,11 @@ impl McapWriterHandle { if let Some(context) = self.context.upgrade() { context.remove_sink(self.sink.id()); } + // Write all accumulated layouts as a single metadata record + let layouts = std::mem::take(&mut *self.layouts.lock().unwrap()); + if !layouts.is_empty() { + self.sink.write_metadata("foxglove.layouts", layouts)?; + } self.sink.finish() } @@ -205,6 +221,36 @@ impl McapWriterHandle { pub fn attach(&self, attachment: &McapAttachment<'_>) -> Result<(), FoxgloveError> { self.sink.attach(attachment) } + + /// Writes a layout to the MCAP file. + /// + /// Layouts are accumulated internally and written as a single metadata record + /// with the name "foxglove.layouts" when the writer is closed. This allows + /// multiple layouts to be stored with different names. + /// + /// # Arguments + /// * `layout_name` - The name to use as the key in the metadata record + /// * `layout` - The layout JSON string to store + /// + /// # Example + /// ```no_run + /// use foxglove::McapWriter; + /// + /// let mcap = McapWriter::new() + /// .create_new_buffered_file("test.mcap") + /// .expect("create failed"); + /// + /// mcap.write_layout("my_layout", r#"{"version":1,"content":{}}"#); + /// mcap.write_layout("another_layout", r#"{"version":1,"content":{}}"#); + /// + /// mcap.close().expect("close failed"); + /// ``` + pub fn write_layout(&self, layout_name: &str, layout: &str) { + self.layouts + .lock() + .unwrap() + .insert(layout_name.to_string(), layout.to_string()); + } } impl Drop for McapWriterHandle {