mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-13 01:14:21 -06:00
Merge storytold/main into feat/windows-monitor-profile
This commit is contained in:
@@ -85,3 +85,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
|
||||
Generated
+16
@@ -3734,6 +3734,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-normalization",
|
||||
"unicode-segmentation",
|
||||
"web-time",
|
||||
]
|
||||
@@ -6618,6 +6619,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
@@ -6824,6 +6831,15 @@ version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
|
||||
@@ -40,6 +40,7 @@ log = { workspace = true }
|
||||
ab_glyph = { workspace = true }
|
||||
# Apply the font’s vertical substitutions and origins to Japanese watermarks.
|
||||
harfrust = { version = "0.13", default-features = false, features = ["std"] }
|
||||
unicode-normalization = "0.1"
|
||||
unicode-segmentation = "1.13"
|
||||
lightcraft-segment = { workspace = true, optional = true }
|
||||
|
||||
|
||||
@@ -57,6 +57,19 @@ pub fn cell_glyphs(font: &FontRef<'_>, data: &harfrust::ShaperData, text: &str,
|
||||
Some(glyphs)
|
||||
}
|
||||
|
||||
/// NFC, except CJK compatibility ideographs, which NFC would swap for their unified forms.
|
||||
fn compose(text: &str) -> String {
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
let compatibility = |c: char| matches!(c, '\u{F900}'..='\u{FAFF}' | '\u{2F800}'..='\u{2FA1F}');
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for part in text.split_inclusive(compatibility) {
|
||||
let kept = part.chars().next_back().filter(|&c| compatibility(c));
|
||||
out.extend(kept.and_then(|c| part.strip_suffix(c)).unwrap_or(part).nfc());
|
||||
out.extend(kept);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A string laid out at one size: its glyphs relative to the box's top-left corner and the box's
|
||||
/// size in pixels (lines are `px` apart; vertical text: columns `px` wide, right to left).
|
||||
pub struct TextLayout {
|
||||
@@ -79,6 +92,9 @@ impl TextLayout {
|
||||
Some((i, _)) => text.get(..i).unwrap_or(text),
|
||||
None => text,
|
||||
};
|
||||
// macOS file names are often decomposed (NFD); the layout below draws one glyph per char
|
||||
let composed = compose(text);
|
||||
let text = composed.as_str();
|
||||
let fonts = faces(craft);
|
||||
let latin = fonts.first()?.clone();
|
||||
let vertical_data: Vec<_> = if vertical {
|
||||
@@ -264,6 +280,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_draws_composed(text: &str, craft: &'static [CraftFont]) {
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
let nfc: String = text.nfc().collect();
|
||||
let nfd: String = text.nfd().collect();
|
||||
assert_ne!(nfc, nfd);
|
||||
let (a, b) = (TextLayout::new(&nfc, 40.0, false, craft).unwrap(), TextLayout::new(&nfd, 40.0, false, craft).unwrap());
|
||||
let ids = |l: &TextLayout| l.glyphs.iter().map(|(face, g, _)| (*face, g.id.0)).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&b), ids(&a));
|
||||
assert_eq!(b.width, a.width);
|
||||
assert!(a.glyphs.iter().all(|(_, g, _)| g.id.0 != 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decomposed_vietnamese_draws_its_composed_letters() {
|
||||
assert_draws_composed("HỌC ĐỂ ỨNG DỤNG", &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decomposed_hangul_draws_whole_syllables() {
|
||||
if !CRAFT_FONTS.iter().any(|font| font.covers("Hang")) {
|
||||
lightcraft_testkit::skip!(Fonts, "built without a Hangul face from craft-fonts");
|
||||
return;
|
||||
}
|
||||
assert_draws_composed("제주도 바다 여행.jpg", CRAFT_FONTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_ideographs_keep_their_code_points() {
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
let nfd = |s: &str| s.nfd().collect::<String>();
|
||||
let text = format!("\u{FA10}{} \u{2F800}{}\u{F900}", nfd("본 제주"), nfd("Ọ"));
|
||||
assert_eq!(compose(&text), "\u{FA10}본 제주 \u{2F800}Ọ\u{F900}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_composed_text_stays_as_it_is() {
|
||||
for text in ["", "plain.jpg", "제주도 바다 여행.jpg", "HỌC ĐỂ ỨNG DỤNG"] {
|
||||
assert!(unicode_normalization::is_nfc(text), "{text}");
|
||||
assert_eq!(compose(text), text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_thai_uses_its_glyphs() {
|
||||
assert_script_glyphs("Thai", "สวัสดีครับ");
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::widgets::{BAND_COLORS, SliderOut, divider, flyout_row, hex, register,
|
||||
|
||||
mod curve;
|
||||
mod grading;
|
||||
mod hdr_flow;
|
||||
mod histogram;
|
||||
mod mixer;
|
||||
mod sliders;
|
||||
@@ -152,7 +153,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
divider(ui);
|
||||
|
||||
section(app, ui, &d, "light", "Light", |app, ui, d| {
|
||||
hdr_row(app, ui, d);
|
||||
hdr_row(app, ui, d, id);
|
||||
for c in ["light.exposure", "light.contrast", "light.highlights", "light.shadows", "light.whites", "light.blacks"] {
|
||||
control(app, ui, d, c, true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
//! The Edit panel's flow between SDR and HDR: pressing HDR moves the histogram and the HDR settings
|
||||
//! to their new layout instead of swapping it in one frame.
|
||||
|
||||
use lightcraft_raster::HdrBins;
|
||||
|
||||
use super::{Color32, Histogram, Mesh, PhotoId, Pos2, Rect, Stroke, Vertex, hex, pos2, vec2};
|
||||
|
||||
/// How long the histogram and the HDR settings take to flow between their SDR and HDR layouts.
|
||||
const FLOW_SECS: f32 = 0.45;
|
||||
|
||||
pub(super) fn hdr_histogram_key(id: PhotoId) -> egui::Id {
|
||||
egui::Id::unique(("hdrHistogram", id))
|
||||
}
|
||||
|
||||
/// The photo's last SDR histogram, kept to draw the way back from the HDR layout and into it.
|
||||
pub(super) fn sdr_histogram_key(id: PhotoId) -> egui::Id {
|
||||
egui::Id::unique(("sdrHistogram", id))
|
||||
}
|
||||
|
||||
/// The histogram on its way between its layouts this frame. Entering HDR squeezes the SDR curve into
|
||||
/// the HDR histogram's SDR section while the HDR section opens beside it (and the legend row slides
|
||||
/// open under the plot); leaving stretches it back. The last histogram of each kind is kept so both
|
||||
/// ends can be drawn on the way.
|
||||
pub(super) struct LayoutFlow {
|
||||
/// 0 (the SDR layout) to 1 (the HDR layout), eased.
|
||||
pub morph: f32,
|
||||
bins: Option<HdrBins>,
|
||||
sdr: Option<Histogram>,
|
||||
hdr: Option<Histogram>,
|
||||
}
|
||||
|
||||
impl LayoutFlow {
|
||||
/// The flow towards the layout of `hist`, the histogram shown this frame.
|
||||
pub fn of(ctx: &egui::Context, id: PhotoId, hist: Option<&Histogram>) -> Self {
|
||||
let showing_hdr = hist.is_some_and(|h| h.hdr.is_some());
|
||||
let morph =
|
||||
egui::emath::easing::cubic_in_out(ctx.animate_bool_with_time(egui::Id::unique(("histogramHdrLayout", id)), showing_hdr, FLOW_SECS));
|
||||
let hdr = if showing_hdr {
|
||||
hist.cloned()
|
||||
} else if morph > 0.0 {
|
||||
ctx.data(|d| d.get_temp::<Histogram>(hdr_histogram_key(id)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sdr = if !showing_hdr {
|
||||
hist.cloned()
|
||||
} else if morph < 1.0 {
|
||||
ctx.data(|d| d.get_temp::<Histogram>(sdr_histogram_key(id)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let bins = hdr.as_ref().and_then(|h| h.hdr);
|
||||
Self { morph: if bins.is_some() { morph } else { 0.0 }, bins, sdr, hdr }
|
||||
}
|
||||
|
||||
/// The HDR section's bins, while any of it is showing.
|
||||
pub fn hdr_bins(&self) -> Option<HdrBins> {
|
||||
self.bins.filter(|_| self.morph > 0.0)
|
||||
}
|
||||
|
||||
/// The histogram being left, drawn alone mid-way when there is none of the new kind yet.
|
||||
pub fn leaving(&self) -> Option<Histogram> {
|
||||
self.hdr.clone().filter(|_| self.morph > 0.0)
|
||||
}
|
||||
|
||||
/// The first HDR bin: where the HDR histogram's SDR section ends.
|
||||
fn from(&self) -> f32 {
|
||||
self.bins.map_or(255.0, |b| b.from as f32)
|
||||
}
|
||||
|
||||
/// SDR white across `plot`: its right edge in SDR, the HDR layout's split in HDR.
|
||||
pub fn sdr_white_x(&self, plot: Rect) -> f32 {
|
||||
plot.left() + plot.width() * sdr_white_at(self.from(), self.morph)
|
||||
}
|
||||
|
||||
/// The curve, one through both layouts: the SDR section's heights morph from the SDR histogram's
|
||||
/// to the HDR histogram's SDR bins as it narrows to SDR white, and the HDR bins fill the rest.
|
||||
/// `shown` decides greyscale, whose three channels coincide: one neutral line is drawn.
|
||||
pub fn paint_curve(&self, p: &egui::Painter, plot: Rect, shown: &Histogram) {
|
||||
let sdr = self.sdr.as_ref().map(histogram_heights);
|
||||
let hdr = self.hdr.as_ref().filter(|_| self.bins.is_some()).map(histogram_heights);
|
||||
let pts = morph_curve(sdr.as_ref(), hdr.as_ref(), self.from(), self.morph);
|
||||
let to = |x: f32, v: f32| pos2(plot.left() + plot.width() * x, plot.bottom() - plot.height() * v);
|
||||
// luminance fill
|
||||
let mut mesh = Mesh::default();
|
||||
for (i, (x, hs)) in pts.iter().enumerate() {
|
||||
let top = to(*x, hs[0]);
|
||||
mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) });
|
||||
mesh.vertices.push(Vertex { pos: pos2(top.x, plot.bottom()), uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) });
|
||||
if i > 0 {
|
||||
let k = (i as u32) * 2;
|
||||
mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]);
|
||||
}
|
||||
}
|
||||
p.add(mesh);
|
||||
let line = |j: usize, c: Color32| egui::Shape::line(pts.iter().map(|(x, hs)| to(*x, hs[j])).collect(), Stroke::new(1.5, c));
|
||||
if is_greyscale(shown) {
|
||||
p.add(line(2, Color32::from_gray(200)));
|
||||
} else {
|
||||
for (j, c) in [(1, hex("#df3939")), (2, hex("#44b072")), (3, hex("#3b6fe0"))] {
|
||||
p.add(line(j, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SDR white across the plot (0..1), `morph` of the way to the HDR layout where it sits at bin `from`.
|
||||
fn sdr_white_at(from: f32, morph: f32) -> f32 {
|
||||
1.0 + (from / 255.0 - 1.0) * morph
|
||||
}
|
||||
|
||||
/// A histogram's display heights per bin for luma, R, G and B: smoothed over 5 bins, then
|
||||
/// square-root scaled against the R/G/B peak (the end bins left out) and capped at 1.
|
||||
fn histogram_heights(h: &Histogram) -> [Vec<f32>; 4] {
|
||||
let smooth = |v: &[u32]| -> Vec<f32> {
|
||||
(0..256)
|
||||
.map(|i: usize| {
|
||||
let (a, b) = (i.saturating_sub(2), (i + 3).min(v.len()));
|
||||
let w = v.get(a..b).unwrap_or_default();
|
||||
w.iter().map(|x| *x as f32).sum::<f32>() / w.len().max(1) as f32
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let [l, r, g, b] = [smooth(&h.luma), smooth(&h.r), smooth(&h.g), smooth(&h.b)];
|
||||
let peak = [&r, &g, &b].iter().flat_map(|v| v.iter().skip(2).take(252)).fold(1.0f32, |a, b| a.max(*b));
|
||||
[l, r, g, b].map(|v| v.into_iter().map(|x| (x / peak).sqrt().min(1.0)).collect())
|
||||
}
|
||||
|
||||
/// `v` at fractional bin `x`, linearly interpolated (clamped to its ends).
|
||||
fn sample(v: &[f32], x: f32) -> f32 {
|
||||
let x = x.clamp(0.0, v.len().saturating_sub(1) as f32);
|
||||
let i = x.floor() as usize;
|
||||
let (a, b) = (v.get(i).copied().unwrap_or(0.0), v.get(i + 1).or(v.get(i)).copied().unwrap_or(0.0));
|
||||
a + (b - a) * (x - i as f32)
|
||||
}
|
||||
|
||||
/// A greyscale image's histogram: its three channels coincide.
|
||||
fn is_greyscale(h: &Histogram) -> bool {
|
||||
let spread: f32 = h.r.iter().zip(&h.g).zip(&h.b).map(|((r, g), b)| (*r as f32 - *g as f32).abs() + (*g as f32 - *b as f32).abs()).sum();
|
||||
spread / (h.g.iter().map(|x| *x as f32).sum::<f32>().max(1.0)) < 0.02
|
||||
}
|
||||
|
||||
/// The histogram curve `morph` of the way from the SDR layout (0) to the HDR one (1), as
|
||||
/// (x across the plot 0..1, heights for luma, R, G, B). Left of SDR white ([`sdr_white_at`]), the SDR
|
||||
/// histogram's heights morph into the HDR histogram's SDR bins (`0..from`); right of it, the HDR
|
||||
/// bins fill what has opened. Either histogram may be missing: the other is drawn alone.
|
||||
fn morph_curve(sdr: Option<&[Vec<f32>; 4]>, hdr: Option<&[Vec<f32>; 4]>, from: f32, morph: f32) -> Vec<(f32, [f32; 4])> {
|
||||
let x0 = sdr_white_at(from, morph);
|
||||
let mut pts: Vec<(f32, [f32; 4])> = (0..=255)
|
||||
.map(|k| {
|
||||
let u = k as f32 / 255.0;
|
||||
let hs = std::array::from_fn(|j| match (sdr, hdr) {
|
||||
(Some(s), Some(c)) => {
|
||||
let (a, z) = (sample(&s[j], u * 255.0), sample(&c[j], u * from));
|
||||
a + (z - a) * morph
|
||||
}
|
||||
(Some(s), None) => sample(&s[j], u * 255.0),
|
||||
(None, Some(c)) => sample(&c[j], u * from),
|
||||
(None, None) => 0.0,
|
||||
});
|
||||
(x0 * u, hs)
|
||||
})
|
||||
.collect();
|
||||
if let Some(c) = hdr.filter(|_| morph > 0.0) {
|
||||
let n = (255.0 - from).max(1.0) as usize;
|
||||
pts.extend((1..=n).map(|k| {
|
||||
let u = k as f32 / n as f32;
|
||||
(x0 + (1.0 - x0) * u, std::array::from_fn(|j| sample(&c[j], from + u * (255.0 - from))))
|
||||
}));
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// `body` as a drawer sliding open (`open`) or shut over [`FLOW_SECS`], eased like the histogram: its
|
||||
/// height grows from 0 to its full height, the rows below move with its edge, and the content slides
|
||||
/// down from under the row above (clipped there: nothing past the edge is drawn or clickable). Fully
|
||||
/// shut, `body` doesn't run.
|
||||
pub(super) fn drawer(ui: &mut egui::Ui, id: egui::Id, open: bool, body: impl FnOnce(&mut egui::Ui)) {
|
||||
let t = egui::emath::easing::cubic_in_out(ui.ctx().animate_bool_with_time(id, open, FLOW_SECS));
|
||||
if t <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// the height measured last frame (0 before the first: that frame draws nothing and measures)
|
||||
let full = ui.ctx().data(|d| d.get_temp::<f32>(id)).unwrap_or(0.0);
|
||||
let avail = ui.available_rect_before_wrap();
|
||||
let top = avail.top();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(avail.translate(vec2(0.0, -full * (1.0 - t)))).layout(*ui.layout()));
|
||||
if t < 1.0 {
|
||||
child.shrink_clip_rect(Rect::from_min_max(pos2(avail.left(), top), pos2(avail.right(), top + full * t)));
|
||||
}
|
||||
body(&mut child);
|
||||
let measured = child.min_rect().height();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id, measured));
|
||||
ui.advance_cursor_after_rect(Rect::from_min_size(pos2(avail.left(), top), vec2(child.min_rect().width(), measured * t)));
|
||||
// the spacing after it grows with it too, so nothing below jumps when it starts or ends
|
||||
ui.add_space(-ui.spacing().item_spacing.y * (1.0 - t));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::Sense;
|
||||
|
||||
/// The whole panel, frame by frame: the HDR button's layout change moves the histogram's height
|
||||
/// and the sliders below in small steps, both ways, and ends at the same layout it left.
|
||||
#[test]
|
||||
fn pressing_hdr_moves_the_panel_instead_of_swapping_its_layout() {
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let widget = |h: &Headless, id: &str| h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r);
|
||||
// (the histogram's height, the Exposure slider's top) at each frame until both reach `end`
|
||||
let run = |h: &mut Headless, on: bool, end: Option<f32>| {
|
||||
h.app.run("develop.hdr", json!({"enabled": on})).expect("develop.hdr");
|
||||
let (t0, mut trail) = (Instant::now(), Vec::new());
|
||||
let mut still = 0;
|
||||
while t0.elapsed() < Duration::from_secs(120) && still < 60 {
|
||||
h.step();
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
let at = |id| widget(h, id).unwrap_or_else(|| panic!("no widget {id}"));
|
||||
let now = (at("histogram").height(), at("slider:light.exposure").top());
|
||||
still = if trail.last() == Some(&now) && end.is_none_or(|e| now.0 == e) { still + 1 } else { 0 };
|
||||
trail.push(now);
|
||||
}
|
||||
trail
|
||||
};
|
||||
let moved = |v: &[f32]| -> (usize, f32) {
|
||||
let (lo, hi) = (v.iter().copied().fold(f32::MAX, f32::min), v.iter().copied().fold(f32::MIN, f32::max));
|
||||
let steps = v.iter().filter(|x| **x > lo && **x < hi).count();
|
||||
(steps, v.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0, f32::max) / (hi - lo).max(1.0))
|
||||
};
|
||||
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1400.0, 1000.0], 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "detail", "right": "edit", "openSections": ["light"]}), Duration::from_secs(20));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(Duration::from_secs(120));
|
||||
let (sdr_h, sdr_y) = (widget(&h, "histogram").expect("histogram").height(), widget(&h, "slider:light.exposure").expect("exposure").top());
|
||||
|
||||
let on = run(&mut h, true, Some(sdr_h + 26.0));
|
||||
let off = run(&mut h, false, Some(sdr_h));
|
||||
for (name, trail) in [("on", &on), ("off", &off)] {
|
||||
let heights: Vec<f32> = trail.iter().map(|t| t.0).collect();
|
||||
let tops: Vec<f32> = trail.iter().map(|t| t.1).collect();
|
||||
let (h_steps, h_jump) = moved(&heights);
|
||||
let (y_steps, y_jump) = moved(&tops);
|
||||
assert!(h_steps >= 4 && h_jump < 0.5, "{name}: the histogram's height jumped: {heights:?}");
|
||||
assert!(y_steps >= 4 && y_jump < 0.5, "{name}: the sliders jumped: {tops:?}");
|
||||
}
|
||||
assert_eq!(on.last().map(|t| t.0), Some(sdr_h + 26.0), "the legend row opened");
|
||||
assert_eq!(off.last().copied(), Some((sdr_h, sdr_y)), "back where it started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hdr_settings_slide_open_and_shut() {
|
||||
let ctx = egui::Context::default();
|
||||
let id = egui::Id::unique("drawer");
|
||||
// the height the drawer takes in the layout at each frame
|
||||
let frame = |time: f64, open: bool| {
|
||||
let mut height = 0.0;
|
||||
let input = egui::RawInput { time: Some(time), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
let top = ui.cursor().top();
|
||||
drawer(ui, id, open, |ui| {
|
||||
ui.allocate_exact_size(vec2(100.0, 40.0), Sense::hover());
|
||||
});
|
||||
height = ui.cursor().top() - top;
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
height
|
||||
};
|
||||
// shut from the start: nothing; opened: it grows a little each frame up to its height
|
||||
assert_eq!(frame(0.0, false), 0.0);
|
||||
let opening: Vec<f32> = (1..=15).map(|k| frame(k as f64 * 0.05, true)).collect();
|
||||
assert!(opening.windows(2).all(|w| w[1] >= w[0]), "{opening:?}");
|
||||
assert!(opening.iter().filter(|h| **h > 0.0 && **h < 40.0).count() >= 4, "{opening:?}");
|
||||
assert!(opening[0] < 3.0, "no jump as it starts: {opening:?}");
|
||||
let spaced = 40.0 + ctx.global_style().spacing.item_spacing.y;
|
||||
assert_eq!(opening.last(), Some(&spaced));
|
||||
// and shrinks back the same way
|
||||
let closing: Vec<f32> = (16..=30).map(|k| frame(k as f64 * 0.05, false)).collect();
|
||||
assert!(closing.windows(2).all(|w| w[1] <= w[0]), "{closing:?}");
|
||||
assert!(closing.iter().filter(|h| **h > 0.0 && **h < 40.0).count() >= 4, "{closing:?}");
|
||||
assert_eq!(closing.last(), Some(&0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_histogram_flows_between_its_sdr_and_hdr_layouts() {
|
||||
let flat = |v: f32| -> [Vec<f32>; 4] { std::array::from_fn(|_| vec![v; 256]) };
|
||||
let (sdr, hdr) = (flat(0.2), flat(0.6));
|
||||
let from = 128.0;
|
||||
let x_of = |pts: &[(f32, [f32; 4])]| pts.iter().map(|p| p.0).collect::<Vec<_>>();
|
||||
// SDR: the SDR histogram across the whole plot, no HDR section
|
||||
let pts = morph_curve(Some(&sdr), Some(&hdr), from, 0.0);
|
||||
assert_eq!(pts.len(), 256);
|
||||
assert!(pts.iter().all(|p| (p.1[0] - 0.2).abs() < 1e-6));
|
||||
assert_eq!(pts.last().map(|p| p.0), Some(1.0));
|
||||
// half way: the SDR section squeezed half way to SDR white, its heights half morphed,
|
||||
// the HDR section opened beside it, joined without a gap
|
||||
let pts = morph_curve(Some(&sdr), Some(&hdr), from, 0.5);
|
||||
let x0 = sdr_white_at(from, 0.5);
|
||||
assert!((pts[255].0 - x0).abs() < 1e-6);
|
||||
assert!((pts[100].1[2] - 0.4).abs() < 1e-6);
|
||||
assert!((pts[256].1[2] - 0.6).abs() < 1e-6 && pts[256].0 > x0);
|
||||
let xs = x_of(&pts);
|
||||
assert!(xs.windows(2).all(|w| w[1] > w[0] && w[1] - w[0] < 0.02), "one continuous curve");
|
||||
assert_eq!(xs.last(), Some(&1.0));
|
||||
// HDR: the HDR histogram alone, SDR white at its bin
|
||||
let pts = morph_curve(Some(&sdr), Some(&hdr), from, 1.0);
|
||||
assert!((pts[255].0 - from / 255.0).abs() < 1e-6);
|
||||
assert!(pts.iter().all(|p| (p.1[0] - 0.6).abs() < 1e-6));
|
||||
// a missing end draws the other alone
|
||||
assert!(morph_curve(None, Some(&hdr), from, 0.3).iter().all(|p| (p.1[1] - 0.6).abs() < 1e-6));
|
||||
assert_eq!(morph_curve(Some(&sdr), None, from, 0.0).len(), 256);
|
||||
assert_eq!((sample(&[0.0, 1.0], 0.25), sample(&[0.0, 1.0], 9.0), sample(&[], 1.0)), (0.25, 1.0, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
use super::hdr_flow::{LayoutFlow, hdr_histogram_key, sdr_histogram_key};
|
||||
#[cfg(test)]
|
||||
use super::sliders::reset_tip;
|
||||
use super::{
|
||||
Align2, Color32, Histogram, Icon, LightcraftApp, Mesh, PhotoId, Pos2, Rect, RichText, RightPanel, Sense, Slot, Stroke, Tokens, Vertex, divider,
|
||||
hex, json, paint, pos2, register, vec2,
|
||||
Align2, Color32, Histogram, Icon, LightcraftApp, PhotoId, Rect, RichText, RightPanel, Sense, Slot, Stroke, Tokens, divider, json, paint, pos2,
|
||||
register, vec2,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------------------ histogram
|
||||
@@ -20,16 +21,21 @@ pub(super) fn preview_histogram(app: &LightcraftApp, id: PhotoId) -> Option<ligh
|
||||
/// An HDR edit's histogram stays an HDR histogram: some renders carry only their 8-bit image's
|
||||
/// (a drag's drafts, a cached thumbnail or stand-in), and showing those would swap the panel to
|
||||
/// the SDR layout and back while a slider moves. Every render with HDR bins replaces the one kept for the photo, so
|
||||
/// the histogram still follows the edit live; an SDR edit shows what it gets.
|
||||
/// the histogram still follows the edit live. An SDR edit likewise never shows an HDR histogram
|
||||
/// (one rendered before HDR was turned off, or a stale thumbnail's): it keeps its last SDR one.
|
||||
fn steady_hdr_histogram(ctx: &egui::Context, id: PhotoId, hdr_edit: bool, hist: Option<Histogram>) -> Option<Histogram> {
|
||||
let key = egui::Id::unique(("hdrHistogram", id));
|
||||
let (hdr_key, sdr_key) = (hdr_histogram_key(id), sdr_histogram_key(id));
|
||||
match hist {
|
||||
Some(h) if h.hdr.is_some() => {
|
||||
ctx.data_mut(|d| d.insert_temp(key, h.clone()));
|
||||
Some(h)
|
||||
ctx.data_mut(|d| d.insert_temp(hdr_key, h.clone()));
|
||||
if hdr_edit { Some(h) } else { ctx.data(|d| d.get_temp::<Histogram>(sdr_key)) }
|
||||
}
|
||||
h if hdr_edit => ctx.data(|d| d.get_temp::<Histogram>(key)).or(h),
|
||||
h => h,
|
||||
Some(h) => {
|
||||
ctx.data_mut(|d| d.insert_temp(sdr_key, h.clone()));
|
||||
if hdr_edit { ctx.data(|d| d.get_temp::<Histogram>(hdr_key)).or(Some(h)) } else { Some(h) }
|
||||
}
|
||||
None if hdr_edit => ctx.data(|d| d.get_temp::<Histogram>(hdr_key)),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,10 +68,12 @@ pub(crate) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId)
|
||||
// rendered it)
|
||||
let hist = live.or_else(|| preview_histogram(app, id));
|
||||
let hist = steady_hdr_histogram(ui.ctx(), id, hdr_edit, hist);
|
||||
// the SDR ⇄ HDR layout change flows instead of cutting
|
||||
let flow = LayoutFlow::of(ui.ctx(), id, hist.as_ref());
|
||||
// an HDR edit's histogram (`pipeline::hdr_histogram`) gets a colour bar and an SDR / HDR label
|
||||
// row under the plot
|
||||
let hdr = hist.as_ref().and_then(|h| h.hdr);
|
||||
let legend = if hdr.is_some() { 26.0 } else { 0.0 };
|
||||
let hdr = flow.hdr_bins();
|
||||
let legend = 26.0 * flow.morph;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click_and_drag());
|
||||
crate::widgets::announce(&resp, crate::i18n::tr("Histogram"));
|
||||
register(ui, "histogram", r);
|
||||
@@ -79,10 +87,11 @@ pub(crate) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId)
|
||||
if let Some(a) = &zone {
|
||||
crate::panels::histogram::paint_zone(p, plot, a, &t);
|
||||
}
|
||||
let x0 = flow.sdr_white_x(plot);
|
||||
if let Some(b) = hdr {
|
||||
// white for SDR, then one band per stop above SDR white: white too, or (Visualize HDR
|
||||
// range on) in its colours
|
||||
let x0 = plot.left() + plot.width() * b.from as f32 / 255.0;
|
||||
// range on) in its colours; the row is revealed as it opens
|
||||
let p = &p.with_clip_rect(Rect::from_min_max(pos2(r.left(), plot.bottom()), pos2(r.right(), plot.bottom() + legend)));
|
||||
let bar = |a: f32, z: f32, c: Color32| p.rect_filled(Rect::from_min_max(pos2(a, plot.bottom() + 2.0), pos2(z, plot.bottom() + 7.0)), 0.0, c);
|
||||
let white = Color32::from_rgb(228, 228, 230);
|
||||
bar(plot.left(), x0 - 1.0, white);
|
||||
@@ -104,18 +113,10 @@ pub(crate) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId)
|
||||
));
|
||||
}
|
||||
let clipping = hist.as_ref().map(|h| h.clipping());
|
||||
if let Some(h) = hist {
|
||||
let smooth = |v: &[u32]| -> Vec<f32> {
|
||||
(0..256)
|
||||
.map(|i| {
|
||||
let (a, b) = (i.max(2) - 2, (i + 3).min(256));
|
||||
v[a..b].iter().map(|x| *x as f32).sum::<f32>() / (b - a) as f32
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// HDR: SDR on the left half, a solid line at SDR white and dashed ones between the stops
|
||||
if let Some(b) = h.hdr {
|
||||
let x0 = plot.left() + plot.width() * b.from as f32 / 255.0;
|
||||
// mid-way with no histogram of the new kind yet, the one being left is drawn alone
|
||||
if let Some(h) = hist.or_else(|| flow.leaving()) {
|
||||
// HDR: SDR on the left, a solid line at SDR white and dashed ones between the stops
|
||||
if let Some(b) = hdr {
|
||||
let stops = b.stops.round().max(1.0) as usize;
|
||||
for k in 1..stops {
|
||||
let x = x0 + (plot.right() - x0) * k as f32 / stops as f32;
|
||||
@@ -134,34 +135,7 @@ pub(crate) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId)
|
||||
p.line_segment([pos2(x, plot.top()), pos2(x, plot.bottom())], Stroke::new(1.0, Color32::from_rgb(230, 150, 60)));
|
||||
}
|
||||
}
|
||||
let chans = [(smooth(&h.r), hex("#df3939")), (smooth(&h.g), hex("#44b072")), (smooth(&h.b), hex("#3b6fe0"))];
|
||||
let peak = chans.iter().flat_map(|(v, _)| v.iter().skip(2).take(252)).fold(1.0f32, |a, b| a.max(*b));
|
||||
let to = |i: usize, v: f32| pos2(plot.left() + plot.width() * i as f32 / 255.0, plot.bottom() - plot.height() * (v / peak).sqrt().min(1.0));
|
||||
// luminance fill
|
||||
let lum = smooth(&h.luma);
|
||||
let mut mesh = Mesh::default();
|
||||
for i in 0..256 {
|
||||
let top = to(i, lum[i]);
|
||||
mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) });
|
||||
mesh.vertices.push(Vertex { pos: pos2(top.x, plot.bottom()), uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) });
|
||||
if i > 0 {
|
||||
let k = (i as u32) * 2;
|
||||
mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]);
|
||||
}
|
||||
}
|
||||
p.add(mesh);
|
||||
// greyscale images: the three channels coincide — draw one neutral line
|
||||
let spread: f32 = (0..256).map(|i| (chans[0].0[i] - chans[1].0[i]).abs() + (chans[1].0[i] - chans[2].0[i]).abs()).sum();
|
||||
let total: f32 = chans[1].0.iter().sum::<f32>().max(1.0);
|
||||
if spread / total < 0.02 {
|
||||
let pts: Vec<Pos2> = (0..256).map(|i| to(i, chans[1].0[i])).collect();
|
||||
p.add(egui::Shape::line(pts, Stroke::new(1.5, Color32::from_gray(200))));
|
||||
} else {
|
||||
for (v, c) in &chans {
|
||||
let pts: Vec<Pos2> = (0..256).map(|i| to(i, v[i])).collect();
|
||||
p.add(egui::Shape::line(pts, Stroke::new(1.5, *c)));
|
||||
}
|
||||
}
|
||||
flow.paint_curve(p, plot, &h);
|
||||
}
|
||||
let mut clicked = resp.clicked();
|
||||
// both buttons keep clear of the panel's right edge, where its floating scroll bar widens when
|
||||
@@ -363,6 +337,10 @@ mod tests {
|
||||
assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(4.0)));
|
||||
// HDR turned off: the SDR histogram at once; other photos keep their own
|
||||
assert_eq!(steady_hdr_histogram(&ctx, id, false, Some(sdr.clone())), Some(sdr.clone()));
|
||||
// an SDR edit never shows an HDR histogram (a stale thumbnail's): its last SDR one instead,
|
||||
// or none yet
|
||||
assert_eq!(steady_hdr_histogram(&ctx, id, false, Some(hdr(4.0))), Some(sdr.clone()));
|
||||
assert_eq!(steady_hdr_histogram(&ctx, PhotoId(9), false, Some(hdr(4.0))), None);
|
||||
assert_eq!(steady_hdr_histogram(&ctx, PhotoId(8), true, Some(sdr.clone())), Some(sdr));
|
||||
}
|
||||
|
||||
|
||||
@@ -214,13 +214,14 @@ pub(super) fn section(
|
||||
|
||||
// ------------------------------------------------------------------------------ HDR
|
||||
|
||||
/// The Light panel's HDR row: the HDR switch and Visualize HDR, then (HDR on) the headroom limit.
|
||||
/// The Light panel's HDR settings while HDR editing is on (the switch itself is the HDR button
|
||||
/// next to B&W): Visualize HDR range and the headroom limit.
|
||||
pub(super) fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
|
||||
if !d.hdr.enabled {
|
||||
return;
|
||||
}
|
||||
/// next to B&W): Visualize HDR range and the headroom limit. They slide open and shut with the
|
||||
/// histogram's change of layout; another photo shows its own at once.
|
||||
pub(super) fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: PhotoId) {
|
||||
super::hdr_flow::drawer(ui, egui::Id::unique(("hdrSettings", id)), d.hdr.enabled, |ui| hdr_settings(app, ui, d));
|
||||
}
|
||||
|
||||
fn hdr_settings(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| {
|
||||
let mut v = app.ui.hdr_visualize;
|
||||
let r = ui.checkbox(&mut v, "Visualize HDR");
|
||||
|
||||
@@ -139,7 +139,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement |
|
||||
| LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) |
|
||||
| LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | |
|
||||
| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit/histogram.rs`, `crates/ui-egui/src/panels/right.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | stays at the top of the Edit panel while its sliders scroll under it (issue #2679, `the_histogram_stays_in_view_while_the_sliders_scroll`); HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; the plot's five tonal zones also drag to adjust their Basic control (LRC-DEV-HISTOGRAM-DRAG); it follows slider drags of an HDR edit live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag; an edit with HDR switched off keeps its SDR histogram while it is dragged (#2819); the shadow and highlight clipping buttons at its top corners are the theme's raised fill with a chevron that stands 3:1 on it in every theme and state |
|
||||
| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit/histogram.rs`, `crates/ui-egui/src/panels/right.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | stays at the top of the Edit panel while its sliders scroll under it (issue #2679, `the_histogram_stays_in_view_while_the_sliders_scroll`); HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; the plot's five tonal zones also drag to adjust their Basic control (LRC-DEV-HISTOGRAM-DRAG); it follows slider drags of an HDR edit live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag; an edit with HDR switched off keeps its SDR histogram while it is dragged (#2819); turning HDR on or off flows between the layouts (`crates/ui-egui/src/panels/edit/hdr_flow.rs`: the SDR curve squeezes into the HDR layout's SDR section as the HDR section and legend open, and stretches back); the shadow and highlight clipping buttons at its top corners are the theme's raised fill with a chevron that stands 3:1 on it in every theme and state |
|
||||
| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | |, `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet; |
|
||||
|
||||
## D. Search & filter (FILT)
|
||||
@@ -177,7 +177,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
|---|---|---|---|---|---|
|
||||
| LR-EDIT-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto`, `crates/pipeline/src/auto.rs` | a Lightroom-like recipe calibrated black-box on Lightroom's Auto results for 24 Sony raws: Exposure from the scene's scene-linear log-average luminance (Lightroom's observed slope, -0.41 EV per EV), Highlights -66 and Shadows +52 (Lightroom's average Auto values), Contrast +68 and Vibrance +30 fitted on our scales. Held out one photo at a time: on the 16 photos whose start Sony's DRO doesn't brighten, a median dE76 of 6.4 from Lightroom's Auto results against 8.3 unedited; on all 24, 7.7 against 7.9 (issues #318, #209). Calibrated on Sony files only; Whites and Blacks stay 0 |
|
||||
| LR-EDIT-BW | Black & white | P0 | ✅ | `cmd:develop.treatment` | |
|
||||
| LR-EDIT-HDR-MODE | HDR editing | P2 | ✅ | `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR button next to B&W in the Edit panel header, as Lightroom (Visualize HDR and the headroom limit in the Light panel while it's on); see Q. HDR |
|
||||
| LR-EDIT-HDR-MODE | HDR editing | P2 | ✅ | `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR button next to B&W in the Edit panel header, as Lightroom (Visualize HDR and the headroom limit in the Light panel while it's on, sliding open and shut with the histogram's layout change); see Q. HDR |
|
||||
| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | Alt/Option-drag shows highlight clipping (black, clipped channels in colour) |
|
||||
| LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | ✅ | `ctl:light.contrast` | |
|
||||
| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp. Alt/Option-drag shows highlight clipping |
|
||||
@@ -399,7 +399,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet |
|
||||
| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | |
|
||||
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | Original export is refused when Remove Location Info is on and the source embeds GPS or a nonblank sublocation, city, state or country; rendered exports and XMP sidecars remove those modeled fields |
|
||||
| LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text uses Inter SemiBold, the existing Mincho-first CJK chain, then Thai faces from `CRAFT_FONTS_DIR`, shared with contact-sheet captions and slideshow overlays (#2052, partial). Horizontal Thai mark positioning remains incomplete. Arabic export fallback is deferred until joining and bidi reordering exist; UI font fallback is unchanged. Text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (EXIF orientation applied before sizing; oversized graphics fit the rotated image bounds without stretching, tested by `graphic_watermark_honours_exif_orientation` and `graphic_watermark_keeps_aspect_when_fitted_and_rotated`; `image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` |
|
||||
| LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text uses Inter SemiBold, the existing Mincho-first CJK chain, then Thai faces from `CRAFT_FONTS_DIR`, shared with contact-sheet captions and slideshow overlays (#2052, partial). Decomposed (NFD) text, as macOS often writes file names, is composed to NFC first, so Hangul syllables and Vietnamese letters draw whole instead of as loose jamo and marks; CJK compatibility ideographs keep their code points (issue #3480, #3277; `decomposed_hangul_draws_whole_syllables`, `decomposed_vietnamese_draws_its_composed_letters`, `compatibility_ideographs_keep_their_code_points`). Horizontal Thai mark positioning remains incomplete. Arabic export fallback is deferred until joining and bidi reordering exist; UI font fallback is unchanged. Text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (EXIF orientation applied before sizing; oversized graphics fit the rotated image bounds without stretching, tested by `graphic_watermark_honours_exif_orientation` and `graphic_watermark_keeps_aspect_when_fitted_and_rotated`; `image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` |
|
||||
| LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes |
|
||||
| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) |
|
||||
| LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet |
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# What's new in LightCraft
|
||||
|
||||
## 2026-11-01
|
||||
|
||||
### Korean and Vietnamese text in exports draws whole
|
||||
- macOS often stores Korean file names decomposed (NFD), each syllable as its jamo. Contact-sheet captions, export
|
||||
watermarks, slideshow overlays and print page text drew those as loose jamo (`ㅈㅔㅈㅜㄷㅗ` for `제주도`), and
|
||||
decomposed Vietnamese letters lost their stacked marks. Text is now composed before it is laid out, so both draw as
|
||||
they do in the app (issue #3480, #3277).
|
||||
|
||||
## 2026-10-30
|
||||
|
||||
### Lens distortion correction for the Sony A7 III
|
||||
|
||||
Reference in New Issue
Block a user