5 Commits

Author SHA1 Message Date
b1bc1f4701 higher quality images
Some checks failed
test / cargo test (push) Failing after 1m12s
2025-07-10 23:48:43 +02:00
8e3e8952a6 fix resolutions for exif rotated images
Some checks failed
test / cargo test (push) Failing after 1m25s
2025-07-09 22:43:02 +02:00
b381ef1335 copy exif data between images
Some checks failed
test / cargo test (push) Failing after 1m15s
2025-07-04 09:02:53 +02:00
db2ff53ed5 fix: Ensure image paths are owned for async processing
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-07-03 22:27:19 +02:00
8e67deb0d7 refactor: Pass Path object to image generation functions 2025-07-03 22:27:16 +02:00
14 changed files with 209 additions and 54 deletions

1
.gitignore vendored
View File

@@ -26,3 +26,4 @@ generated_images/
node_modules/
package-lock.json
.aider*

View File

@@ -19,7 +19,7 @@ tower-http = { version = "0.6.0", features = ["trace", "fs"] }
tower-livereload = "0.9.2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
image = "0.25.2"
image = "0.25.6"
anyhow = "1.0.86"
rayon = "1.10.0"
syntect = "5.2.0"

View File

@@ -0,0 +1,59 @@
---
layout: blog
title: Somewhere in the sky
segments:
- blog
published: true
date: 2025-06-11T15:50:45.527Z
thumbnail: /images/uploads/dscf4399.jpg
tags:
- News
- Personal
- Travel
---
## Realization
What can you do on an airplane when you cannot move as your son is sleeping on your lap? You can **write a new blog post**, "obviously". Why can't I find more time to write more posts? I lack the motivation, to be honest. Now that anyone can just ask an LLM to spit out an original blog post on demand, who will search for original, honest content written by a human?
I've set expectations for my wife that this year we won't be going on a vacation, and she has overruled and found a one-week trip to Mallorca. I can confirm that we will be **coming back to Mallorca** for further exploration. This week was very nice. I haven't made any commitments on what I'm going to do with my career. I've already **accomplished** that since this year, **I will work on Rust projects**.
## Drift
In my latest vlog, I mentioned that I should talk more about burning out, and that I don't feel like I am burning out. Since then, I stopped streaming and stopped producing videos. **Did I jinx myself?** I don't know. Being active on social media is not something for me. I enjoy streaming, but I just had to put my family above my fulfillment. **I enjoy putting my son to sleep** much more. He is growing so fast. I expect that in a few years **we will start streaming together** as he explores the world of computers.
I expect to find **more time for streaming** when the sun falls in earlier hours.
You should've seen him. He was able to fill his bucket and pour it into a hole in the sand more than 100 times in a row. Where he finds the energy to do this is unbelievable. He is such an extraordinary individual.
## Inertia
*What about the game progress?*
**The game is in a terrible state**. I realized that the **whole animation system has to be rewritten** as it is unreliable. And that's almost everything that we have been working on. I've started to **write the game design document** so I don't lose my thoughts on mechanics and game setting. I should find more time to code as well, but currently, **I have just enough work and studies to keep me occupied**.
## Sight
Here are some photos from the trip:
![My beautiful family](/images/uploads/dscf4379.jpg "My beautiful fam")
![Chasing dreams](/images/uploads/img_20250607_202440.jpg "Chasing dreams")
![The sun for my moon](/images/uploads/dscf4333.jpg "The sun for my moon")
![Stomp](/images/uploads/dscf4274.jpg "Stomp")
![Lil bird](/images/uploads/dscf4399.jpg "Lil bird")
![Fuji X-Michal with Viltrox AF 23/1.4 XF](/images/uploads/img_20250610_204627.jpg "Fuji X-Michal Viltrox AF 23/1.4 XF")
Some of them are taken with my *Fuji X-Michal* and I have to say I've fallen in love with the machine. I wish some features were more accessible, and I hope with the new Fuji X-E5 announcement that the *Film simulation recipes* will be included in a future firmware upgrade. The current configuration is very misleading and not user-friendly.
I've also ordered a new Sigma 16-300, hoping it will be delivered before the vacation. I have already **been waiting for it for 2 months**, and still no indication of when it will arrive. I am pretty new to the hobby, and it looks like when ordering a new gear, there is a **guarantee of a long waiting time**, or it is out of stock pretty quickly.
I'd like to **post more pictures** here on the blog. I'm planning on releasing albums. I don't know if I should also make some changes to the visualization, or if the current blog page rendering system is fine as it is. **Let me know if you have some cool ideas**.
I'm going to keep it short. We've landed successfully. The vacation was amazing. We are definitely coming back to Mallorca. Expect more content (soon).
Happy summer.

View File

@@ -8,7 +8,8 @@ use syntect::{highlighting::ThemeSet, html::highlighted_html_for_string, parsing
use tracing::{debug, error};
use crate::picture_generator::{
picture_markup_generator::generate_picture_markup, resolutions::get_max_resolution,
picture_markup_generator::{generate_picture_markup, should_swap_dimensions},
resolutions::get_max_resolution,
};
pub const MAX_BLOG_IMAGE_RESOLUTION: (u32, u32) = (1280, 860);
@@ -65,8 +66,16 @@ pub fn parse_markdown<T: fmt::Display>(
let dev_only_img_path =
Path::new("static/").join(dest_url.strip_prefix("/").unwrap_or(&dest_url));
let img_dimensions = image_dimensions(&dev_only_img_path).unwrap();
// We need to take the exif rotation into consideration here
let img_dimensions = {
let orig_img_dimensions = image_dimensions(&dev_only_img_path).unwrap();
if should_swap_dimensions(&dev_only_img_path) {
(orig_img_dimensions.1, orig_img_dimensions.0)
} else {
orig_img_dimensions
}
};
let (max_width, max_height) = get_max_resolution(
img_dimensions,
MAX_BLOG_IMAGE_RESOLUTION.0,

View File

@@ -1,5 +1,4 @@
use image::ImageFormat;
#[allow(dead_code)]
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum ExportFormat {
Jpeg,
@@ -25,12 +24,4 @@ impl ExportFormat {
ExportFormat::Png => "image/png",
}
}
pub fn get_image_format(&self) -> ImageFormat {
match self {
ExportFormat::Jpeg => ImageFormat::Jpeg,
ExportFormat::Avif => ImageFormat::Avif,
ExportFormat::Svg => ImageFormat::Jpeg, // TODO what now?
ExportFormat::Png => ImageFormat::Png,
}
}
}

View File

@@ -1,6 +1,14 @@
use std::{fs::create_dir_all, path::Path};
use std::{
fs::{create_dir_all, File},
io::BufWriter,
path::Path,
};
use image::{imageops::FilterType, DynamicImage};
use image::{
codecs::{jpeg::JpegEncoder, png::PngEncoder},
imageops::FilterType,
DynamicImage,
};
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use tracing::{debug, error};
@@ -8,6 +16,7 @@ use super::export_format::ExportFormat;
pub fn generate_images(
image: &DynamicImage,
disk_image_path: &Path,
path_to_generated: &Path,
resolutions: &[(u32, u32, f32)],
formats: &[ExportFormat],
@@ -15,7 +24,6 @@ pub fn generate_images(
formats.par_iter().for_each(|format| {
resolutions.par_iter().for_each(|resolution| {
let (width, height, _) = *resolution;
// let image = image.clone();
let resized = image.resize_to_fill(width, height, FilterType::Triangle);
let file_name = path_to_generated.file_name().unwrap().to_str().unwrap();
let save_path = Path::new("./")
@@ -33,13 +41,32 @@ pub fn generate_images(
create_dir_all(parent_dir).unwrap();
}
let result = resized.save_with_format(&save_path, format.get_image_format());
match result {
let encode = {
let buffered_file_write = &mut BufWriter::new(File::create(&save_path).unwrap()); // always seekable
match format {
ExportFormat::Jpeg => {
let encoder = JpegEncoder::new_with_quality(buffered_file_write, 87);
resized.write_with_encoder(encoder)
}
ExportFormat::Avif => todo!(),
ExportFormat::Svg => todo!(),
ExportFormat::Png => {
let encoder = PngEncoder::new_with_quality(
buffered_file_write,
image::codecs::png::CompressionType::Best,
image::codecs::png::FilterType::Adaptive,
);
resized.write_with_encoder(encoder)
}
}
};
match encode {
Err(err) => {
error!("Failed to generate {:?} - {:?}", &save_path, err);
}
_ => {
Ok(_) => {
debug!("Generated image {:?}", &save_path);
let _ = copy_exif(disk_image_path, &save_path);
}
}
});
@@ -47,3 +74,22 @@ pub fn generate_images(
Ok(())
}
pub fn copy_exif(orig_path: &Path, save_path: &Path) -> Result<(), anyhow::Error> {
let status = std::process::Command::new("exiftool")
.args([
"-TagsFromFile",
orig_path.to_str().expect("Orig path should exist"),
"-exif:all",
"-overwrite_original",
save_path.to_str().expect("Save path of image should exist"),
])
.status()?;
if status.success() {
debug!("EXIF copied successfully.");
} else {
error!("Failed to copy EXIF.");
}
Ok(())
}

View File

@@ -8,13 +8,15 @@ use super::{
picture_markup_generator::{get_export_formats, get_generated_file_name, get_image_path},
};
/// Used directly in templates
pub fn generate_image_with_src(
orig_img_path: &str,
width: u32,
height: u32,
suffix: &str,
) -> Result<String, anyhow::Error> {
let path_to_generated = get_generated_file_name(orig_img_path);
let orig_path = Path::new(orig_img_path);
let path_to_generated = get_generated_file_name(orig_path);
let file_stem = path_to_generated.file_stem().unwrap().to_str().unwrap();
let path_to_generated = path_to_generated.with_file_name(format!("{file_stem}{suffix}"));
@@ -22,7 +24,7 @@ pub fn generate_image_with_src(
Path::new("static/").join(orig_img_path.strip_prefix("/").unwrap_or(orig_img_path));
let resolutions = [(width, height, 1.)];
let exported_formats = get_export_formats(orig_img_path);
let exported_formats = get_export_formats(orig_path);
if exported_formats.is_empty() {
return Ok(orig_img_path.to_string());
@@ -39,11 +41,11 @@ pub fn generate_image_with_src(
.unwrap()
.decode()
.unwrap();
let path_to_generated = path_to_generated_clone.as_ref();
let result = generate_images(
&orig_img,
path_to_generated,
&disk_img_path,
path_to_generated_clone.as_ref(),
&resolutions,
&[exported_format],
)

View File

@@ -4,7 +4,7 @@ use std::{
};
use anyhow::Context;
use image::{image_dimensions, ImageReader};
use image::{image_dimensions, ImageDecoder, ImageReader};
use indoc::formatdoc;
use super::{
@@ -14,6 +14,7 @@ use super::{
pub const PIXEL_DENSITIES: [f32; 5] = [1., 1.5, 2., 3., 4.];
/// Used by markdown generator
pub fn generate_picture_markup(
orig_img_path: &str,
width: u32,
@@ -21,13 +22,15 @@ pub fn generate_picture_markup(
alt_text: &str,
class_name: Option<&str>,
) -> Result<String, anyhow::Error> {
let exported_formats = get_export_formats(orig_img_path);
let orig_path = Path::new(orig_img_path);
let exported_formats = get_export_formats(orig_path);
let class_attr = if let Some(class) = class_name {
format!(r#"class="{class}""#)
} else {
"".to_string()
};
// Here the resolution is already correct
if exported_formats.is_empty() {
return Ok(formatdoc!(
r#"<img
@@ -39,14 +42,19 @@ pub fn generate_picture_markup(
>"#
));
}
let path_to_generated = get_generated_file_name(orig_img_path);
let path_to_generated = get_generated_file_name(orig_path);
let disk_img_path =
Path::new("static/").join(orig_img_path.strip_prefix("/").unwrap_or(orig_img_path));
// Here we have a problem. The resolution is swapped but we want to generate images with original dimensions which are not here.
let orig_img_dimensions = image_dimensions(&disk_img_path)?;
let resolutions = get_resolutions(orig_img_dimensions, width, height);
let resolutions = get_resolutions(
orig_img_dimensions,
width,
height,
should_swap_dimensions(&disk_img_path),
);
let path_to_generated_arc = Arc::new(path_to_generated);
let path_to_generated_clone = Arc::clone(&path_to_generated_arc);
let resolutions_arc = Arc::new(resolutions);
@@ -60,11 +68,14 @@ pub fn generate_picture_markup(
.unwrap()
.decode()
.unwrap();
let path_to_generated = path_to_generated_clone.as_ref();
let resolutions = resolutions_clone.as_ref();
let exported_formats = exported_formats_clone.as_ref();
let result = generate_images(&orig_img, path_to_generated, resolutions, exported_formats)
let result = generate_images(
&orig_img,
&disk_img_path,
&path_to_generated_clone,
&resolutions_clone,
&exported_formats_clone,
)
.with_context(|| "Failed to generate images".to_string());
if let Err(e) = result {
tracing::error!("Error: {}", e);
@@ -123,11 +134,19 @@ pub fn get_image_path(path: &Path, resolution: &(u32, u32, f32), format: &Export
format!("{path_name}_{width}x{height}.{extension}")
}
/// Take original resolution of photo and
/// exif data is not taken into consideration therefore we don't need to do anything here regarding to swapping width-height
fn get_resolutions(
(orig_width, orig_height): (u32, u32),
width: u32,
height: u32,
swap_dimensions: bool,
) -> Vec<(u32, u32, f32)> {
let (width, height) = if swap_dimensions {
(height, width)
} else {
(width, height)
};
let mut resolutions: Vec<(u32, u32, f32)> = vec![];
for pixel_density in PIXEL_DENSITIES {
let (density_width, density_height) = (
@@ -152,22 +171,22 @@ fn get_resolutions(
#[test]
fn test_get_resolutions() {
assert_eq!(
get_resolutions((320, 200), 320, 200),
get_resolutions((320, 200), 320, 200, false),
vec![(320, 200, 1.)],
"Only original size fits"
);
assert_eq!(
get_resolutions((500, 400), 320, 200),
get_resolutions((500, 400), 320, 200, false),
vec![(320, 200, 1.), (480, 300, 1.5), (500, 312, 2.)],
"Should only create sizes that fits and fill the max possible for the last one - width"
);
assert_eq!(
get_resolutions((400, 600), 300, 200),
get_resolutions((400, 600), 300, 200, false),
vec![(300, 200, 1.), (400, 266, 1.5)],
"Should only create sizes that fits and fill the max possible for the last one - height"
);
assert_eq!(
get_resolutions((1200, 900), 320, 200),
get_resolutions((1200, 900), 320, 200, false),
vec![
(320, 200, 1.),
(480, 300, 1.5),
@@ -177,6 +196,7 @@ fn test_get_resolutions() {
],
"Should create all possible sizes, with the last one maxed"
);
// TODO add test for swapping
}
fn strip_prefixes(path: &Path) -> &Path {
@@ -193,15 +213,9 @@ fn strip_prefixes(path: &Path) -> &Path {
parent_path
}
pub fn get_generated_file_name(orig_img_path: &str) -> PathBuf {
let path = Path::new(&orig_img_path);
// let parent = path
// .parent()
// .expect("There should be a parent route to an image")
// .strip_prefix(".")
// .unwrap();
let parent = strip_prefixes(path);
let file_name = path
pub fn get_generated_file_name(orig_img_path: &Path) -> PathBuf {
let parent = strip_prefixes(orig_img_path);
let file_name = orig_img_path
.file_stem()
.expect("There should be a name for every img");
let result = Path::new("/generated_images/")
@@ -216,7 +230,7 @@ pub fn get_generated_file_name(orig_img_path: &str) -> PathBuf {
#[test]
fn test_get_generated_paths() {
let orig_img_path = "/images/uploads/img_name.jpg";
let orig_img_path = Path::new("/images/uploads/img_name.jpg");
assert_eq!(
get_generated_file_name(orig_img_path)
.to_str()
@@ -233,9 +247,9 @@ fn generate_srcset(path: &Path, format: &ExportFormat, resolutions: &[(u32, u32,
let (width, height, density) = resolution;
let path_name = path.to_str().expect("Path to an image has to be valid");
let formatted_density = if density.fract() == 0.0 {
format!("{}", density) // Convert to integer if there is no decimal part
format!("{density}") // Convert to integer if there is no decimal part
} else {
format!("{:.1}", density) // Format to 1 decimal place if there is a fractional part
format!("{density:.1}") // Format to 1 decimal place if there is a fractional part
};
format!("{path_name}_{width}x{height}.{extension} {formatted_density}x")
})
@@ -243,10 +257,8 @@ fn generate_srcset(path: &Path, format: &ExportFormat, resolutions: &[(u32, u32,
.join(", ")
}
pub fn get_export_formats(orig_img_path: &str) -> Vec<ExportFormat> {
let path = Path::new(&orig_img_path)
.extension()
.and_then(|ext| ext.to_str());
pub fn get_export_formats(orig_img_path: &Path) -> Vec<ExportFormat> {
let path = orig_img_path.extension().and_then(|ext| ext.to_str());
match path {
// THINK: Do we want to enable avif? It's very expensive to encode
@@ -258,10 +270,27 @@ pub fn get_export_formats(orig_img_path: &str) -> Vec<ExportFormat> {
}
}
pub fn should_swap_dimensions(img_path: &Path) -> bool {
let orientation = ImageReader::open(img_path)
.unwrap()
.into_decoder()
.unwrap()
.orientation()
.unwrap();
matches!(
orientation,
image::metadata::Orientation::Rotate90
| image::metadata::Orientation::Rotate270
| image::metadata::Orientation::Rotate90FlipH
| image::metadata::Orientation::Rotate270FlipH
)
}
#[test]
fn test_get_export_formats() {
assert_eq!(
get_export_formats("/images/uploads/img_name.jpg"),
get_export_formats(Path::new("/images/uploads/img_name.jpg")),
// vec![ExportFormat::Avif, ExportFormat::Jpeg]
vec![ExportFormat::Jpeg]
)

BIN
static/images/uploads/dscf4274.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
static/images/uploads/dscf4333.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
static/images/uploads/dscf4379.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
static/images/uploads/dscf4399.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
static/images/uploads/img_20250607_202440.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
static/images/uploads/img_20250610_204627.jpg (Stored with Git LFS) Normal file

Binary file not shown.