Skip to content

Commit ba58089

Browse files
authored
Show the blurhash of media attachments (#1370)
This uses https://github.com/whisperfish/blurhash-rs The implementation uses the "stupid" approach of recompiling the blur every "app_logic" call, but the rebuilds are rare enough that it's fine in practise <img width="966" height="955" alt="image" src="https://github.com/user-attachments/assets/87738d10-02f5-4594-8245-c22661162802" /> This also avoids needing to think about retaining resources for images, so that's positive.
1 parent 6595477 commit ba58089

File tree

5 files changed

+149
-3
lines changed

5 files changed

+149
-3
lines changed

Cargo.lock

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

placehero/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ tracing.workspace = true
1515
xilem = { version = "0.3.0", path = "../xilem" }
1616
image = { workspace = true, features = ["jpeg", "png"] }
1717
anyhow = "1.0.98"
18+
blurhash = "0.2.3"
1819

1920
[lints]
2021
workspace = true

placehero/src/avatars.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ impl Avatars {
4242
/// This will fetch the image data from the URL, and cache it.
4343
/// If the image hasn't yet loaded, will show a placeholder,
4444
///
45-
/// Requires that this View is within a [`Self::provide`] call.
45+
/// Requires that this View is within a [`Self::provide`] call.
4646
// TODO: ArcStr for URL?
4747
pub(crate) fn avatar<State: 'static, Action: 'static>(
4848
url: String,

placehero/src/components.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ pub(crate) use timeline::Timeline;
1818
mod thread;
1919
pub(crate) use thread::thread;
2020

21+
mod media;
22+
2123
/// Renders the key parts of a Status, in a shared way.
2224
///
2325
/// This is the shared functionality between a timeline and the list of views.
@@ -60,7 +62,14 @@ fn base_status<State: 'static>(
6062
.text_alignment(TextAlign::End),
6163
))
6264
.must_fill_major_axis(true),
63-
prose(status_html_to_plaintext(status.content.as_str())),
65+
prose(status_html_to_plaintext(status.content.as_str())).flex(CrossAxisAlignment::Start),
66+
status
67+
.media_attachments
68+
.iter()
69+
.map(|attachment| {
70+
media::attachment::<State>(attachment).flex(CrossAxisAlignment::Start)
71+
})
72+
.collect::<Vec<_>>(),
6473
flex_row((
6574
label(format!("💬 {}", status.replies_count)).flex(1.0),
6675
label(format!("🔄 {}", status.reblogs_count)).flex(1.0),
@@ -70,6 +79,7 @@ fn base_status<State: 'static>(
7079
}),
7180
))
7281
// TODO: The "extra space" amount actually ends up being zero, so this doesn't do anything.
73-
.main_axis_alignment(MainAxisAlignment::SpaceEvenly),
82+
.main_axis_alignment(MainAxisAlignment::SpaceEvenly)
83+
.flex(CrossAxisAlignment::Start),
7484
)
7585
}

placehero/src/components/media.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright 2025 the Xilem Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
use std::{
5+
sync::Arc,
6+
time::{Duration, Instant},
7+
};
8+
9+
use megalodon::entities::{Attachment, attachment::AttachmentType};
10+
use xilem::{
11+
Blob, Image, ImageFormat, WidgetView,
12+
core::one_of::{OneOf, OneOf4},
13+
masonry::properties::types::AsUnit,
14+
view::{flex_col, image, prose, sized_box},
15+
};
16+
17+
use crate::actions::Navigation;
18+
19+
/// Render a single media attachment for use in a status.
20+
///
21+
/// This currently doesn't perform any caching.
22+
pub(crate) fn attachment<State: 'static>(
23+
attachment: &Attachment,
24+
) -> impl WidgetView<State, Navigation> + use<State> {
25+
match attachment.r#type {
26+
AttachmentType::Audio => OneOf4::A(audio_attachment(attachment)),
27+
AttachmentType::Video | AttachmentType::Gifv => OneOf::B(video_attachment(attachment)),
28+
AttachmentType::Image => OneOf::C(image_attachment(attachment)),
29+
AttachmentType::Unknown => OneOf::D(flex_col((
30+
maybe_blurhash(attachment),
31+
prose("Unknown media"),
32+
))),
33+
}
34+
}
35+
36+
/// If the attachment has a [blurhash], create a view of it.
37+
///
38+
/// This view currently does not cache the blurhash, or take any other steps to
39+
/// avoid recalculating the image.
40+
/// We haven't ran into this being a performance issue.
41+
fn maybe_blurhash<State: 'static>(
42+
attachment: &Attachment,
43+
) -> Option<impl WidgetView<State, Navigation> + use<State>> {
44+
let start = Instant::now();
45+
let blurhash = attachment.blurhash.as_deref()?;
46+
// TODO: Maybe use `memoize` here? We don't at the moment because we
47+
// wouldn't have any way to pass the "attachment" in.
48+
let (width, height) = 'dimensions: {
49+
if let Some(meta) = &attachment.meta {
50+
if let Some(width) = meta.width
51+
&& let Some(height) = meta.height
52+
{
53+
break 'dimensions (width, height);
54+
} else if let Some(original) = &meta.original
55+
&& let Some(width) = original.width
56+
&& let Some(height) = original.height
57+
{
58+
break 'dimensions (width, height);
59+
}
60+
}
61+
tracing::error!("Couldn't find dimensions for blurhash. Using default.");
62+
(960, 960)
63+
};
64+
// TODO: Shrink width and height in a more sane way (e.g. aspect ratio which gets closest to 64x64?)
65+
let blur_width = width / 16;
66+
let blur_height = height / 16;
67+
let result_bytes = blurhash::decode(blurhash, blur_width, blur_height, 1.0).ok()?;
68+
let image_data = Blob::new(Arc::new(result_bytes));
69+
// This image format doesn't seem to be documented by the blurhash crate, but this value seems to work.
70+
let image2 = Image::new(image_data, ImageFormat::Rgba8, blur_width, blur_height);
71+
let took = start.elapsed();
72+
if took > Duration::from_millis(5) {
73+
tracing::info!("Calculating a blurhash (size {blur_width}x{blur_height}) took {took:?}.");
74+
}
75+
76+
// Retain the aspect ratio, and don't go bigger than the image's actual dimensions.
77+
// Prefer to fill up the width rather than the height.
78+
Some(sized_box(sized_box(image(&image2)).expand_width()).width(width.px()))
79+
}
80+
81+
/// Show some useful info for audio attachments.
82+
fn audio_attachment<State: 'static>(
83+
attachment: &Attachment,
84+
) -> impl WidgetView<State, Navigation> + use<State> {
85+
flex_col((
86+
maybe_blurhash(attachment),
87+
prose("Audio File - Unsupported"),
88+
attachment.meta.as_ref().map(|meta| {
89+
meta.length
90+
.as_deref()
91+
.map(|it| prose(format!("Length: {it}")))
92+
}),
93+
attachment.description.as_deref().map(prose),
94+
))
95+
}
96+
97+
/// Show some useful info for audio attachments.
98+
fn video_attachment<State: 'static>(
99+
attachment: &Attachment,
100+
) -> impl WidgetView<State, Navigation> + use<State> {
101+
flex_col((
102+
maybe_blurhash(attachment),
103+
prose("Video File - Unsupported"),
104+
attachment.meta.as_ref().map(|meta| {
105+
meta.length
106+
.as_deref()
107+
.map(|it| prose(format!("Length: {it}")))
108+
}),
109+
attachment.description.as_deref().map(prose),
110+
))
111+
}
112+
113+
/// Show some useful info for audio attachments.
114+
fn image_attachment<State: 'static>(
115+
attachment: &Attachment,
116+
) -> impl WidgetView<State, Navigation> + use<State> {
117+
flex_col((
118+
maybe_blurhash(attachment),
119+
prose("Image File - Unsupported"),
120+
attachment.meta.as_ref().map(|meta| {
121+
meta.size
122+
.as_deref()
123+
.or_else(|| meta.original.as_ref().and_then(|it| it.size.as_deref()))
124+
.map(|it| prose(format!("Dimensions: {it}")))
125+
}),
126+
attachment.description.as_deref().map(prose),
127+
))
128+
}

0 commit comments

Comments
 (0)