Skip to content

Commit a16b61a

Browse files
emilkteh-cmc
andauthored
Save recordings from web viewer (#5488)
### What You can now save recordings from the web viewer. The UI is admittedly pretty ugly (we use [rfd](https://github.com/PolyMeilex/rfd)), but it works. ### Checklist * [x] I have read and agree to [Contributor Guide](https://githubcom/rerun-io/rerun/blob/main/CONTRIBUTING.md) and the [Code of Conduct](https://github.com/rerun-io/rerun/blob/main/CODE_OF_CONDUCT.md) * [x] I've included a screenshot or gif (if applicable) * [x] I have tested the web demo (if applicable): * Using newly built examples: [app.rerun.io](https://app.rerun.io/pr/5488/index.html) * Using examples from latest `main` build: [app.rerun.io](https://app.rerun.io/pr/5488/index.html?manifest_url=https://app.rerun.io/version/main/examples_manifest.json) * Using full set of examples from `nightly` build: [app.rerun.io](https://app.rerun.io/pr/5488/index.html?manifest_url=https://app.rerun.io/version/nightly/examples_manifest.json) * [x] The PR title and labels are set such as to maximize their usefulness for the next release's CHANGELOG * [x] If applicable, add a new check to the [release checklist](https://github.com/rerun-io/rerun/blob/main/tests/python/release_checklist)! - [PR Build Summary](https://build.rerun.io/pr/5488) - [Docs preview](https://rerun.io/preview/ebf6bf5a428eeabe6b61d1bfe9a5f2c578775bcd/docs) <!--DOCS-PREVIEW--> - [Examples preview](https://rerun.io/preview/ebf6bf5a428eeabe6b61d1bfe9a5f2c578775bcd/examples) <!--EXAMPLES-PREVIEW--> - [Recent benchmark results](https://build.rerun.io/graphs/crates.html) - [Wasm size tracking](https://build.rerun.io/graphs/sizes.html) --------- Co-authored-by: Clement Rey <cr.rey.clement@gmail.com>
1 parent 4292f47 commit a16b61a

File tree

11 files changed

+160
-117
lines changed

11 files changed

+160
-117
lines changed

crates/re_data_store/src/store_read.rs

+1
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,7 @@ impl DataStore {
397397

398398
/// Sort all unsorted indices in the store.
399399
pub fn sort_indices_if_needed(&self) {
400+
re_tracing::profile_function!();
400401
for index in self.tables.values() {
401402
index.sort_indices_if_needed();
402403
}

crates/re_entity_db/src/entity_db.rs

+41-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ use re_data_store::{
88
DataStore, DataStoreConfig, GarbageCollectionOptions, StoreEvent, StoreSubscriber,
99
};
1010
use re_log_types::{
11-
ApplicationId, DataCell, DataRow, DataTable, EntityPath, EntityPathHash, LogMsg, RowId,
12-
SetStoreInfo, StoreId, StoreInfo, StoreKind, TimePoint, Timeline,
11+
ApplicationId, DataCell, DataRow, DataTable, DataTableResult, EntityPath, EntityPathHash,
12+
LogMsg, RowId, SetStoreInfo, StoreId, StoreInfo, StoreKind, TimePoint, TimeRange, TimeRangeF,
13+
Timeline,
1314
};
1415
use re_types_core::{components::InstanceKey, Archetype, Loggable};
1516

@@ -510,6 +511,44 @@ impl EntityDb {
510511
self.store_info()
511512
.map(|info| (info.application_id.0.as_str(), info.started))
512513
}
514+
515+
/// Export the contents of the current database to a sequence of messages.
516+
///
517+
/// If `time_selection` is specified, then only data for that specific timeline over that
518+
/// specific time range will be accounted for.
519+
pub fn to_messages(
520+
&self,
521+
time_selection: Option<(Timeline, TimeRangeF)>,
522+
) -> DataTableResult<Vec<LogMsg>> {
523+
re_tracing::profile_function!();
524+
525+
self.store().sort_indices_if_needed();
526+
527+
let set_store_info_msg = self
528+
.store_info_msg()
529+
.map(|msg| LogMsg::SetStoreInfo(msg.clone()));
530+
531+
let time_filter = time_selection.map(|(timeline, range)| {
532+
(
533+
timeline,
534+
TimeRange::new(range.min.floor(), range.max.ceil()),
535+
)
536+
});
537+
538+
let data_messages = self.store().to_data_tables(time_filter).map(|table| {
539+
table
540+
.to_arrow_msg()
541+
.map(|msg| LogMsg::ArrowMsg(self.store_id().clone(), msg))
542+
});
543+
544+
let messages: Result<Vec<_>, _> = set_store_info_msg
545+
.map(re_log_types::DataTableResult::Ok)
546+
.into_iter()
547+
.chain(data_messages)
548+
.collect();
549+
550+
messages
551+
}
513552
}
514553

515554
// ----------------------------------------------------------------------------

crates/re_log_encoding/src/encoder.rs

+11-7
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ impl<W: std::io::Write> Encoder<W> {
7575
}
7676

7777
pub fn append(&mut self, message: &LogMsg) -> Result<(), EncodeError> {
78+
re_tracing::profile_function!();
79+
7880
self.uncompressed.clear();
7981
rmp_serde::encode::write_named(&mut self.uncompressed, message)?;
8082

@@ -119,21 +121,23 @@ pub fn encode<'a>(
119121
messages: impl Iterator<Item = &'a LogMsg>,
120122
write: &mut impl std::io::Write,
121123
) -> Result<(), EncodeError> {
124+
re_tracing::profile_function!();
122125
let mut encoder = Encoder::new(options, write)?;
123126
for message in messages {
124127
encoder.append(message)?;
125128
}
126129
Ok(())
127130
}
128131

129-
pub fn encode_owned(
132+
pub fn encode_as_bytes<'a>(
130133
options: EncodingOptions,
131-
messages: impl Iterator<Item = LogMsg>,
132-
write: impl std::io::Write,
133-
) -> Result<(), EncodeError> {
134-
let mut encoder = Encoder::new(options, write)?;
134+
messages: impl Iterator<Item = &'a LogMsg>,
135+
) -> Result<Vec<u8>, EncodeError> {
136+
re_tracing::profile_function!();
137+
let mut bytes: Vec<u8> = vec![];
138+
let mut encoder = Encoder::new(options, &mut bytes)?;
135139
for message in messages {
136-
encoder.append(&message)?;
140+
encoder.append(message)?;
137141
}
138-
Ok(())
142+
Ok(bytes)
139143
}

crates/re_log_encoding/src/lib.rs

-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
#[cfg(feature = "decoder")]
44
pub mod decoder;
55
#[cfg(feature = "encoder")]
6-
#[cfg(not(target_arch = "wasm32"))] // we do no yet support encoding LogMsgs in the browser
76
pub mod encoder;
87

98
#[cfg(feature = "encoder")]
@@ -121,7 +120,6 @@ impl FileHeader {
121120
pub const SIZE: usize = 12;
122121

123122
#[cfg(feature = "encoder")]
124-
#[cfg(not(target_arch = "wasm32"))] // we do no yet support encoding LogMsgs in the browser
125123
pub fn encode(&self, write: &mut impl std::io::Write) -> Result<(), encoder::EncodeError> {
126124
write
127125
.write_all(&self.magic)
@@ -165,7 +163,6 @@ impl MessageHeader {
165163
pub const SIZE: usize = 8;
166164

167165
#[cfg(feature = "encoder")]
168-
#[cfg(not(target_arch = "wasm32"))] // we do no yet support encoding LogMsgs in the browser
169166
pub fn encode(&self, write: &mut impl std::io::Write) -> Result<(), encoder::EncodeError> {
170167
write
171168
.write_all(&self.compressed_len.to_le_bytes())

crates/re_ui/examples/re_ui_example.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -541,8 +541,8 @@ impl ExampleApp {
541541
}
542542

543543
fn file_menu(ui: &mut egui::Ui, command_sender: &CommandSender) {
544-
UICommand::Save.menu_button_ui(ui, command_sender);
545-
UICommand::SaveSelection.menu_button_ui(ui, command_sender);
544+
UICommand::SaveRecording.menu_button_ui(ui, command_sender);
545+
UICommand::SaveRecordingSelection.menu_button_ui(ui, command_sender);
546546
UICommand::Open.menu_button_ui(ui, command_sender);
547547
UICommand::Quit.menu_button_ui(ui, command_sender);
548548
}

crates/re_ui/src/command.rs

+7-14
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,8 @@ pub trait UICommandSender {
1414
pub enum UICommand {
1515
// Listed in the order they show up in the command palette by default!
1616
Open,
17-
#[cfg(not(target_arch = "wasm32"))]
18-
Save,
19-
#[cfg(not(target_arch = "wasm32"))]
20-
SaveSelection,
17+
SaveRecording,
18+
SaveRecordingSelection,
2119
CloseCurrentRecording,
2220
#[cfg(not(target_arch = "wasm32"))]
2321
Quit,
@@ -95,12 +93,10 @@ impl UICommand {
9593

9694
pub fn text_and_tooltip(self) -> (&'static str, &'static str) {
9795
match self {
98-
#[cfg(not(target_arch = "wasm32"))]
99-
Self::Save => ("Save…", "Save all data to a Rerun data file (.rrd)"),
96+
Self::SaveRecording => ("Save recording…", "Save all data to a Rerun data file (.rrd)"),
10097

101-
#[cfg(not(target_arch = "wasm32"))]
102-
Self::SaveSelection => (
103-
"Save loop selection…",
98+
Self::SaveRecordingSelection => (
99+
"Save recording (current time selection only)…",
104100
"Save data for the current loop selection to a Rerun data file (.rrd)",
105101
),
106102

@@ -238,7 +234,6 @@ impl UICommand {
238234
KeyboardShortcut::new(Modifiers::COMMAND, key)
239235
}
240236

241-
#[cfg(not(target_arch = "wasm32"))]
242237
fn cmd_alt(key: Key) -> KeyboardShortcut {
243238
KeyboardShortcut::new(Modifiers::COMMAND.plus(Modifiers::ALT), key)
244239
}
@@ -248,10 +243,8 @@ impl UICommand {
248243
}
249244

250245
match self {
251-
#[cfg(not(target_arch = "wasm32"))]
252-
Self::Save => Some(cmd(Key::S)),
253-
#[cfg(not(target_arch = "wasm32"))]
254-
Self::SaveSelection => Some(cmd_alt(Key::S)),
246+
Self::SaveRecording => Some(cmd(Key::S)),
247+
Self::SaveRecordingSelection => Some(cmd_alt(Key::S)),
255248
Self::Open => Some(cmd(Key::O)),
256249
Self::CloseCurrentRecording => None,
257250

crates/re_viewer/src/app.rs

+70-16
Original file line numberDiff line numberDiff line change
@@ -442,18 +442,17 @@ impl App {
442442
cmd: UICommand,
443443
) {
444444
match cmd {
445-
#[cfg(not(target_arch = "wasm32"))]
446-
UICommand::Save => {
445+
UICommand::SaveRecording => {
447446
save(self, store_context, None);
448447
}
449-
#[cfg(not(target_arch = "wasm32"))]
450-
UICommand::SaveSelection => {
448+
UICommand::SaveRecordingSelection => {
451449
save(
452450
self,
453451
store_context,
454452
self.state.loop_selection(store_context),
455453
);
456454
}
455+
457456
#[cfg(not(target_arch = "wasm32"))]
458457
UICommand::Open => {
459458
for file_path in open_file_dialog_native() {
@@ -1501,41 +1500,96 @@ async fn async_open_rrd_dialog() -> Vec<re_data_source::FileContents> {
15011500
file_contents
15021501
}
15031502

1504-
#[cfg(not(target_arch = "wasm32"))]
1503+
#[allow(clippy::needless_pass_by_ref_mut)]
15051504
fn save(
1506-
app: &mut App,
1505+
#[allow(unused_variables)] app: &mut App, // only used on native
15071506
store_context: Option<&StoreContext<'_>>,
15081507
loop_selection: Option<(re_entity_db::Timeline, re_log_types::TimeRangeF)>,
15091508
) {
1510-
use crate::saving::save_database_to_file;
1509+
re_tracing::profile_function!();
15111510

15121511
let Some(entity_db) = store_context.as_ref().and_then(|view| view.recording) else {
15131512
// NOTE: Can only happen if saving through the command palette.
15141513
re_log::error!("No data to save!");
15151514
return;
15161515
};
15171516

1517+
let file_name = "data.rrd";
1518+
15181519
let title = if loop_selection.is_some() {
15191520
"Save loop selection"
15201521
} else {
15211522
"Save"
15221523
};
15231524

1524-
if let Some(path) = rfd::FileDialog::new()
1525-
.set_file_name("data.rrd")
1526-
.set_title(title)
1527-
.save_file()
1525+
// Web
1526+
#[cfg(target_arch = "wasm32")]
15281527
{
1529-
let f = match save_database_to_file(entity_db, path, loop_selection) {
1530-
Ok(f) => f,
1528+
let messages = match entity_db.to_messages(loop_selection) {
1529+
Ok(messages) => messages,
15311530
Err(err) => {
15321531
re_log::error!("File saving failed: {err}");
15331532
return;
15341533
}
15351534
};
1536-
if let Err(err) = app.background_tasks.spawn_file_saver(f) {
1537-
// NOTE: Can only happen if saving through the command palette.
1538-
re_log::error!("File saving failed: {err}");
1535+
1536+
wasm_bindgen_futures::spawn_local(async move {
1537+
if let Err(err) = async_save_dialog(file_name, title, &messages).await {
1538+
re_log::error!("File saving failed: {err}");
1539+
}
1540+
});
1541+
}
1542+
1543+
// Native
1544+
#[cfg(not(target_arch = "wasm32"))]
1545+
{
1546+
let path = {
1547+
re_tracing::profile_scope!("file_dialog");
1548+
rfd::FileDialog::new()
1549+
.set_file_name(file_name)
1550+
.set_title(title)
1551+
.save_file()
1552+
};
1553+
if let Some(path) = path {
1554+
let messages = match entity_db.to_messages(loop_selection) {
1555+
Ok(messages) => messages,
1556+
Err(err) => {
1557+
re_log::error!("File saving failed: {err}");
1558+
return;
1559+
}
1560+
};
1561+
if let Err(err) = app
1562+
.background_tasks
1563+
.spawn_file_saver(move || crate::saving::encode_to_file(&path, messages.iter()))
1564+
{
1565+
// NOTE: Can only happen if saving through the command palette.
1566+
re_log::error!("File saving failed: {err}");
1567+
}
15391568
}
15401569
}
15411570
}
1571+
1572+
#[cfg(target_arch = "wasm32")]
1573+
async fn async_save_dialog(
1574+
file_name: &str,
1575+
title: &str,
1576+
messages: &[LogMsg],
1577+
) -> anyhow::Result<()> {
1578+
use anyhow::Context as _;
1579+
1580+
let file_handle = rfd::AsyncFileDialog::new()
1581+
.set_file_name(file_name)
1582+
.set_title(title)
1583+
.save_file()
1584+
.await;
1585+
1586+
let Some(file_handle) = file_handle else {
1587+
return Ok(()); // aborted
1588+
};
1589+
1590+
let bytes = re_log_encoding::encoder::encode_as_bytes(
1591+
re_log_encoding::EncodingOptions::COMPRESSED,
1592+
messages.iter(),
1593+
)?;
1594+
file_handle.write(&bytes).await.context("Failed to save")
1595+
}

crates/re_viewer/src/saving.rs

+9-55
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
#[cfg(not(target_arch = "wasm32"))]
2-
use re_entity_db::EntityDb;
3-
41
#[cfg(not(target_arch = "wasm32"))]
52
use re_log_types::ApplicationId;
63

@@ -65,60 +62,17 @@ pub fn default_blueprint_path(app_id: &ApplicationId) -> anyhow::Result<std::pat
6562
}
6663

6764
#[cfg(not(target_arch = "wasm32"))]
68-
/// Returns a closure that, when run, will save the contents of the current database
69-
/// to disk, at the specified `path`.
70-
///
71-
/// If `time_selection` is specified, then only data for that specific timeline over that
72-
/// specific time range will be accounted for.
73-
pub fn save_database_to_file(
74-
entity_db: &EntityDb,
75-
path: std::path::PathBuf,
76-
time_selection: Option<(re_entity_db::Timeline, re_log_types::TimeRangeF)>,
77-
) -> anyhow::Result<impl FnOnce() -> anyhow::Result<std::path::PathBuf>> {
78-
use re_data_store::TimeRange;
79-
65+
pub fn encode_to_file<'a>(
66+
path: &std::path::Path,
67+
messages: impl Iterator<Item = &'a re_log_types::LogMsg>,
68+
) -> anyhow::Result<()> {
8069
re_tracing::profile_function!();
81-
82-
entity_db.store().sort_indices_if_needed();
83-
84-
let set_store_info_msg = entity_db
85-
.store_info_msg()
86-
.map(|msg| LogMsg::SetStoreInfo(msg.clone()));
87-
88-
let time_filter = time_selection.map(|(timeline, range)| {
89-
(
90-
timeline,
91-
TimeRange::new(range.min.floor(), range.max.ceil()),
92-
)
93-
});
94-
let data_msgs: Result<Vec<_>, _> = entity_db
95-
.store()
96-
.to_data_tables(time_filter)
97-
.map(|table| {
98-
table
99-
.to_arrow_msg()
100-
.map(|msg| LogMsg::ArrowMsg(entity_db.store_id().clone(), msg))
101-
})
102-
.collect();
103-
10470
use anyhow::Context as _;
105-
use re_log_types::LogMsg;
106-
let data_msgs = data_msgs.with_context(|| "Failed to export to data tables")?;
107-
108-
let msgs = std::iter::once(set_store_info_msg)
109-
.flatten() // option
110-
.chain(data_msgs);
111-
112-
Ok(move || {
113-
re_tracing::profile_scope!("save_to_file");
11471

115-
use anyhow::Context as _;
116-
let file = std::fs::File::create(path.as_path())
117-
.with_context(|| format!("Failed to create file at {path:?}"))?;
72+
let mut file = std::fs::File::create(path)
73+
.with_context(|| format!("Failed to create file at {path:?}"))?;
11874

119-
let encoding_options = re_log_encoding::EncodingOptions::COMPRESSED;
120-
re_log_encoding::encoder::encode_owned(encoding_options, msgs, file)
121-
.map(|_| path)
122-
.context("Message encode")
123-
})
75+
let encoding_options = re_log_encoding::EncodingOptions::COMPRESSED;
76+
re_log_encoding::encoder::encode(encoding_options, messages, &mut file)
77+
.context("Message encode")
12478
}

0 commit comments

Comments
 (0)