Skip to content

Commit 41a86c8

Browse files
authored
Experimental python API for taking viewer screenshots (#12405)
### Related * Fixes #2305 * it's not perfect, comes with #12481 * #12482 ### What Users long wanted to programmatically write out screenshots from Rerun for all sort of purposes. This is now possible! Usage ```py """Take screenshots of the viewer or specific views from code.""" import rerun as rr import rerun.blueprint as rrb from rerun.experimental import ViewerClient # Setup a viewer with a known blueprint. rr.init("rerun_example_screenshot", spawn=True) rr.connect_grpc() view = rrb.Spatial3DView(name="my blue 3D", background=[100, 149, 237]) rr.send_blueprint(rrb.Blueprint(view, collapse_panels=True)) # Connect to a local viewer. viewer = ViewerClient() # Screenshot the entire viewer. viewer.save_screenshot("entire_viewer.jpg") # Screenshot only the view we created earlier. viewer.save_screenshot("my_view.png", view_id=view.id) ``` https://github.com/user-attachments/assets/ddb7d0dd-574a-4412-9c8e-3eabf77ad482 To make this _really_ useful a few more things need to be done - future work: * allow blocking on when the screenshot is done * improve `send_blueprint` ergonomics a little bit * provide an example screenshotting out a camera path (needs above items) * not directly related but makes this much more powerful: allow setting time cursor from viewer client * work with a windows-less (headless) viewer * address views by name rather than id (best effort?) Draft TODOs: * [x] a few code todos * [x] take uuid argument on view_id so we don't have to call `str(view.id)` * [x] put the above snippet into some docs * [x] make future work items an issue, check if #2305 is really to be closed fully already and whether there's other issues Source-Ref: 0bb67f5af9231fc6e1f599d4c9f49e72906914bd
1 parent c9372b1 commit 41a86c8

File tree

17 files changed

+438
-44
lines changed

17 files changed

+438
-44
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9076,6 +9076,7 @@ dependencies = [
90769076
name = "re_log_channel"
90779077
version = "0.29.0-alpha.1+dev"
90789078
dependencies = [
9079+
"camino",
90799080
"crossbeam",
90809081
"parking_lot",
90819082
"re_byte_size",
@@ -10444,6 +10445,7 @@ dependencies = [
1044410445
"bit-vec",
1044510446
"bitflags 2.9.4",
1044610447
"bytemuck",
10448+
"camino",
1044710449
"crossbeam",
1044810450
"datafusion",
1044910451
"directories",

crates/store/re_grpc_server/src/lib.rs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use std::net::SocketAddr;
77
use std::pin::Pin;
88

99
use re_byte_size::SizeBytes;
10-
use re_log_channel::DataSourceMessage;
10+
use re_log_channel::{DataSourceMessage, DataSourceUiCommand};
1111
use re_log_encoding::{ToApplication as _, ToTransport as _};
1212
use re_log_types::TableMsg;
1313
use re_protos::common::v1alpha1::{
@@ -16,8 +16,8 @@ use re_protos::common::v1alpha1::{
1616
use re_protos::log_msg::v1alpha1::LogMsg as LogMsgProto;
1717
use re_protos::sdk_comms::v1alpha1::{
1818
ReadMessagesRequest, ReadMessagesResponse, ReadTablesRequest, ReadTablesResponse,
19-
WriteMessagesRequest, WriteMessagesResponse, WriteTableRequest, WriteTableResponse,
20-
message_proxy_service_server,
19+
SaveScreenshotRequest, SaveScreenshotResponse, WriteMessagesRequest, WriteMessagesResponse,
20+
WriteTableRequest, WriteTableResponse, message_proxy_service_server,
2121
};
2222
use tokio::net::TcpListener;
2323
use tokio::sync::{broadcast, mpsc, oneshot};
@@ -413,6 +413,8 @@ pub fn spawn_with_recv(
413413
}
414414
},
415415

416+
Ok(LogOrTableMsgProto::UiCommand(cmd)) => Ok(DataSourceMessage::UiCommand(cmd)),
417+
416418
Err(broadcast::error::RecvError::Closed) => {
417419
re_log::debug!("message proxy server shut down, closing receiver");
418420
channel_log_tx.quit(None).ok();
@@ -474,20 +476,21 @@ struct TableMsgProto {
474476
id: TableIdProto,
475477
data: DataframePartProto,
476478
}
477-
478479
// -----------------------------------------------------------------------------------
479480

480481
#[derive(Clone)]
481482
enum LogOrTableMsgProto {
482483
LogMsg(LogMsgProto),
483484
Table(TableMsgProto),
485+
UiCommand(DataSourceUiCommand),
484486
}
485487

486488
impl LogOrTableMsgProto {
487489
fn total_size_bytes(&self) -> u64 {
488490
match self {
489491
Self::LogMsg(log_msg) => log_msg.total_size_bytes(),
490492
Self::Table(table) => table.total_size_bytes(),
493+
Self::UiCommand(cmd) => cmd.total_size_bytes(),
491494
}
492495
}
493496
}
@@ -504,6 +507,12 @@ impl From<TableMsgProto> for LogOrTableMsgProto {
504507
}
505508
}
506509

510+
impl From<DataSourceUiCommand> for LogOrTableMsgProto {
511+
fn from(value: DataSourceUiCommand) -> Self {
512+
Self::UiCommand(value)
513+
}
514+
}
515+
507516
// -----------------------------------------------------------------------------------
508517

509518
#[derive(Default)]
@@ -611,6 +620,9 @@ impl MessageBuffer {
611620
LogOrTableMsgProto::Table(msg) => {
612621
self.disposable.push_back(msg.into());
613622
}
623+
LogOrTableMsgProto::UiCommand(msg) => {
624+
self.disposable.push_back(msg.into());
625+
}
614626
}
615627
}
616628

@@ -826,6 +838,10 @@ impl MessageProxy {
826838
self.event_tx.send(Event::Message(table.into())).await.ok();
827839
}
828840

841+
async fn push_ui_command(&self, cmd: DataSourceUiCommand) {
842+
self.event_tx.send(Event::Message(cmd.into())).await.ok();
843+
}
844+
829845
async fn new_client_message_stream(&self) -> ReadMsgStream {
830846
let (sender, receiver) = oneshot::channel();
831847
if let Err(err) = self.event_tx.send(Event::NewClient(sender)).await {
@@ -869,6 +885,10 @@ impl MessageProxy {
869885
re_log::warn_once!("A log stream got a TableMsg");
870886
None
871887
}
888+
Ok(ReadLogOrTableMsgResponse::UiCommand) => {
889+
re_log::warn_once!("A log stream got a UiCommandMsg");
890+
None
891+
}
872892
Err(err) => Some(Err(err)),
873893
}),
874894
)
@@ -884,6 +904,10 @@ impl MessageProxy {
884904
None
885905
}
886906
Ok(ReadLogOrTableMsgResponse::TableMsg(msg)) => Some(Ok(msg)),
907+
Ok(ReadLogOrTableMsgResponse::UiCommand) => {
908+
re_log::warn_once!("A log stream got a UiCommandMsg");
909+
None
910+
}
887911
Err(err) => Some(Err(err)),
888912
}),
889913
)
@@ -893,6 +917,7 @@ impl MessageProxy {
893917
enum ReadLogOrTableMsgResponse {
894918
LogMsg(ReadMessagesResponse),
895919
TableMsg(ReadTablesResponse),
920+
UiCommand,
896921
}
897922

898923
impl From<LogOrTableMsgProto> for ReadLogOrTableMsgResponse {
@@ -905,6 +930,7 @@ impl From<LogOrTableMsgProto> for ReadLogOrTableMsgResponse {
905930
id: Some(table_msg.id),
906931
data: Some(table_msg.data),
907932
}),
933+
LogOrTableMsgProto::UiCommand(_ui_command) => Self::UiCommand,
908934
}
909935
}
910936
}
@@ -982,6 +1008,20 @@ impl message_proxy_service_server::MessageProxyService for MessageProxy {
9821008
) -> tonic::Result<tonic::Response<Self::ReadTablesStream>> {
9831009
Ok(tonic::Response::new(self.new_client_table_stream().await))
9841010
}
1011+
1012+
async fn save_screenshot(
1013+
&self,
1014+
request: tonic::Request<SaveScreenshotRequest>,
1015+
) -> tonic::Result<tonic::Response<SaveScreenshotResponse>> {
1016+
let SaveScreenshotRequest { view_id, file_path } = request.into_inner();
1017+
self.push_ui_command(DataSourceUiCommand::SaveScreenshot {
1018+
file_path: file_path.into(),
1019+
view_id,
1020+
})
1021+
.await;
1022+
1023+
Ok(tonic::Response::new(SaveScreenshotResponse {}))
1024+
}
9851025
}
9861026

9871027
#[cfg(test)]

crates/store/re_log_channel/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ re_quota_channel.workspace = true
2626
re_tracing.workspace = true
2727
re_uri.workspace = true
2828

29+
camino.workspace = true
2930
crossbeam.workspace = true
3031
parking_lot.workspace = true
3132
serde.workspace = true

crates/store/re_log_channel/src/data_source_message.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,29 @@ pub enum DataSourceUiCommand {
7777
// Not using `re_uri::Fragment` to avoid further dependency entanglement.
7878
fragment: String, //re_uri::Fragment,
7979
},
80+
81+
/// Save a screenshot to a file.
82+
SaveScreenshot {
83+
/// File path to save the screenshot to.
84+
// TODO(#12482): Returning the screenshot to the caller would be more flexible and useful.
85+
file_path: camino::Utf8PathBuf,
86+
87+
/// Optional view id to screenshot a specific view.
88+
///
89+
/// If none is provided, the entire viewer is screenshotted.
90+
view_id: Option<String>,
91+
},
92+
}
93+
94+
impl re_byte_size::SizeBytes for DataSourceUiCommand {
95+
fn heap_size_bytes(&self) -> u64 {
96+
match self {
97+
Self::SetUrlFragment { store_id, fragment } => {
98+
store_id.heap_size_bytes() + fragment.heap_size_bytes()
99+
}
100+
Self::SaveScreenshot { file_path, view_id } => {
101+
file_path.capacity() as u64 + view_id.heap_size_bytes()
102+
}
103+
}
104+
}
80105
}

crates/store/re_protos/proto/rerun/v1alpha1/sdk_comms.proto

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ service MessageProxyService {
2222

2323
rpc WriteTable(WriteTableRequest) returns (WriteTableResponse) {}
2424
rpc ReadTables(ReadTablesRequest) returns (stream ReadTablesResponse) {}
25+
26+
rpc SaveScreenshot(SaveScreenshotRequest) returns (SaveScreenshotResponse) {}
2527
}
2628

2729
// WriteMessages
@@ -57,3 +59,16 @@ message ReadTablesResponse {
5759
rerun.common.v1alpha1.TableId id = 1;
5860
rerun.common.v1alpha1.DataframePart data = 2;
5961
}
62+
63+
// SaveScreenshot
64+
65+
message SaveScreenshotRequest {
66+
// Optional view ID to screenshot. If omitted, screenshots the entire viewer.
67+
optional string view_id = 1;
68+
69+
// Path at which the screenshot will be saved.
70+
// This path is relative to the current working directory of the viewer process.
71+
string file_path = 2;
72+
}
73+
74+
message SaveScreenshotResponse {}

crates/store/re_protos/src/v1alpha1/rerun.sdk_comms.v1alpha1.rs

Lines changed: 97 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)