Merge pull request #405 from storytold/claude/integrate-batch-10
Fix #310 (CR2/PEF starting look) and integrate #402 (menus stay below the menu bar)
This commit is contained in:
commit
32cedb3153
|
|
@ -24,7 +24,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
|||
|---|---:|---|---|
|
||||
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF and CR3 have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
|
|
@ -47,8 +47,9 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
|||
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
|
||||
|
||||
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF have the file-local fit
|
||||
(matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity.
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2
|
||||
and Pentax PEF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
|
||||
checked on the corpus); then ORF, then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
|
||||
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
|
||||
no LGPL dependency); compressed NEF (#86) is the template.
|
||||
|
|
@ -186,7 +187,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
|
|||
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF and CR3 files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
|
|
|||
|
|
@ -336,12 +336,13 @@ impl Photo {
|
|||
pub fn develops_raw(&self) -> bool {
|
||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
||||
}
|
||||
/// The current ARW, NEF, RW2, RAF and CR3 readers have vendor WB multipliers but no measured camera
|
||||
/// The current ARW, NEF, RW2, RAF, CR3, CR2 and PEF readers have vendor WB multipliers but no measured camera
|
||||
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
|
||||
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
|
||||
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
|
||||
pub fn relative_wb(&self) -> bool {
|
||||
self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
self.develops_raw()
|
||||
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ pub(crate) const PROFILE_PROXY: usize = 192;
|
|||
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
|
||||
/// same formats by file extension.
|
||||
pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3)
|
||||
matches!(
|
||||
format,
|
||||
RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3 | RawFormat::Cr2 | RawFormat::Pef
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
|
|
@ -1017,8 +1020,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn supported_raws_get_a_file_local_look() {
|
||||
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3].into_iter().all(file_local_look));
|
||||
assert!(![RawFormat::Dng, RawFormat::Cr2].into_iter().any(file_local_look));
|
||||
// every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback)
|
||||
let local = [RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3, RawFormat::Cr2, RawFormat::Pef];
|
||||
assert!(local.into_iter().all(file_local_look));
|
||||
// DNG carries its own colour model
|
||||
assert!(!file_local_look(RawFormat::Dng));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ impl SettingsHashes {
|
|||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 14;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 15;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
|
|||
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::{DevelopSettings, WbMode};
|
||||
for format in ["ARW", "NEF", "NRW", "RAF"] {
|
||||
for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF"] {
|
||||
let mut s = demo();
|
||||
let id = PhotoId(100);
|
||||
let name = format!("synthetic.{format}");
|
||||
|
|
|
|||
|
|
@ -1287,6 +1287,8 @@
|
|||
"Scanning": "Wird durchsucht",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Die Werte bleiben an den Fotos gespeichert: Filtere danach oder erstelle Smart-Alben mit Schärfe und „Beste ähnlicher Aufnahmen“.",
|
||||
"Screen": "Bildschirm",
|
||||
"Scroll menu down": "Menü nach unten scrollen",
|
||||
"Scroll menu up": "Menü nach oben scrollen",
|
||||
"Search": "Suche",
|
||||
"Search Photos": "Fotos suchen",
|
||||
"Search commands or keys": "Befehle oder Tasten suchen",
|
||||
|
|
|
|||
|
|
@ -1235,6 +1235,8 @@
|
|||
"Scanning": "Explorando",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Las puntuaciones se quedan en las fotos: se pueden filtrar o crear álbumes inteligentes con Enfoque y La mejor de las tomas similares.",
|
||||
"Screen": "Pantalla",
|
||||
"Scroll menu down": "Desplazar el menú hacia abajo",
|
||||
"Scroll menu up": "Desplazar el menú hacia arriba",
|
||||
"Search": "Buscar",
|
||||
"Search Photos": "Buscar fotos",
|
||||
"Search commands or keys": "Buscar comandos o teclas",
|
||||
|
|
|
|||
|
|
@ -1172,6 +1172,8 @@
|
|||
"Scanning": "検索中",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "評価結果は写真に保存されます。ピント評価や類似写真内のベストショットで絞り込み・スマートアルバム作成ができます",
|
||||
"Screen": "画面用",
|
||||
"Scroll menu down": "メニューを下にスクロール",
|
||||
"Scroll menu up": "メニューを上にスクロール",
|
||||
"Search": "検索",
|
||||
"Search Photos": "写真を検索",
|
||||
"Search commands or keys": "コマンドまたはキーを検索",
|
||||
|
|
|
|||
|
|
@ -1245,6 +1245,8 @@
|
|||
"Scanning": "Examinando",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "As pontuações ficam nas fotos: filtre ou crie álbuns inteligentes com Foco e Melhor de fotos semelhantes.",
|
||||
"Screen": "Tela",
|
||||
"Scroll menu down": "Rolar o menu para baixo",
|
||||
"Scroll menu up": "Rolar o menu para cima",
|
||||
"Search": "Pesquisar",
|
||||
"Search Photos": "Pesquisar fotos",
|
||||
"Search commands or keys": "Pesquisar comandos ou teclas",
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,8 @@
|
|||
"Scanning": "Сканирование",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Оценки остаются на фото: фильтруйте или создавайте смарт-альбомы по Focus и Best of Similar Shots.",
|
||||
"Screen": "Экран",
|
||||
"Scroll menu down": "Прокрутить меню вниз",
|
||||
"Scroll menu up": "Прокрутить меню вверх",
|
||||
"Search Photos": "Поиск фото",
|
||||
"Search commands or keys": "Поиск команд или клавиш",
|
||||
"Search…": "Поиск…",
|
||||
|
|
|
|||
|
|
@ -1233,6 +1233,8 @@
|
|||
"Scanning": "正在扫描",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "评分会保留在照片上:可使用“对焦”和“相似照片中的最佳”进行筛选或创建智能相册。",
|
||||
"Screen": "屏幕",
|
||||
"Scroll menu down": "向下滚动菜单",
|
||||
"Scroll menu up": "向上滚动菜单",
|
||||
"Search": "搜索",
|
||||
"Search Photos": "搜索照片",
|
||||
"Search commands or keys": "搜索命令或按键",
|
||||
|
|
|
|||
|
|
@ -1233,6 +1233,8 @@
|
|||
"Scanning": "正在掃描",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "分數會保留在照片上:可依焦點與相似照片中的最佳照片篩選,或建立智慧型相簿。",
|
||||
"Screen": "螢幕",
|
||||
"Scroll menu down": "向下捲動選單",
|
||||
"Scroll menu up": "向上捲動選單",
|
||||
"Search": "搜尋",
|
||||
"Search Photos": "搜尋照片",
|
||||
"Search commands or keys": "搜尋命令或按鍵",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub mod icons;
|
|||
pub mod import;
|
||||
pub mod lightroom_import;
|
||||
pub mod links;
|
||||
pub mod menu_level;
|
||||
pub mod menubar;
|
||||
pub mod menus;
|
||||
pub mod merge;
|
||||
|
|
@ -44,6 +45,8 @@ mod tests_masking;
|
|||
#[cfg(test)]
|
||||
mod tests_masking_layout;
|
||||
#[cfg(test)]
|
||||
mod tests_menubar;
|
||||
#[cfg(test)]
|
||||
mod tests_offline;
|
||||
#[cfg(test)]
|
||||
mod tests_panels;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
//! Menus bounded by the window (#189). egui keeps a popup inside the window: a menu or submenu
|
||||
//! taller than the room below the menu bar was slid up over the bar, which hid the menu titles,
|
||||
//! and the pointer that had rested on the title then rested on the menu's first row and opened
|
||||
//! its submenu by itself. Here every level of a menu gets only the room it has below the bar (a
|
||||
//! submenu: from its row down, or up to the bar when egui opens it upward) and scrolls inside
|
||||
//! it: wheel, dragging, and ▲ / ▼ strips that scroll while the pointer rests on them, like
|
||||
//! native menus on a small display. The same approach as PhotoCraft's menu bar (its #319).
|
||||
|
||||
use egui::{Id, Rect, Sense, Ui, pos2, vec2};
|
||||
|
||||
/// Height of a scroll-arrow strip.
|
||||
pub const ARROW: f32 = 16.0;
|
||||
/// Gap kept between a menu and the window edge.
|
||||
const EDGE: f32 = 6.0;
|
||||
/// How fast resting on a scroll arrow scrolls, in points per second.
|
||||
const SPEED: f32 = 600.0;
|
||||
|
||||
fn anchor_id(depth: usize) -> Id {
|
||||
Id::new(("lc-menu-anchor", depth))
|
||||
}
|
||||
|
||||
/// Remember the row the submenu at `depth` (2 = a top-level menu's submenu) hangs from. It is
|
||||
/// read on the next frame: a submenu is drawn while its row is, before the row's rect is known.
|
||||
pub fn set_anchor(ctx: &egui::Context, depth: usize, row: Rect) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| d.insert_temp(anchor_id(depth), (row, pass)));
|
||||
}
|
||||
|
||||
/// The row the submenu at `depth` hung from on the previous frame (none when it has just opened,
|
||||
/// or when a stored row belongs to a submenu that has closed since).
|
||||
fn anchor(ctx: &egui::Context, depth: usize) -> Option<Rect> {
|
||||
anchor_at(ctx.data(|d| d.get_temp(anchor_id(depth))), ctx.cumulative_pass_nr())
|
||||
}
|
||||
|
||||
/// The anchor `stored` as `(row, pass it was set on)`, if that was the pass before `pass`.
|
||||
fn anchor_at(stored: Option<(Rect, u64)>, pass: u64) -> Option<Rect> {
|
||||
stored.filter(|(_, at)| at.saturating_add(1) == pass).map(|(row, _)| row)
|
||||
}
|
||||
|
||||
/// The height a menu level's rows may take. Every level stays below the menu bar: a top-level
|
||||
/// menu hangs from it, and a submenu that is too tall for the window between its row and the
|
||||
/// bottom, or (opening upward, as egui does when that fits) between the bar and its row, scrolls
|
||||
/// instead of egui sliding it up over the bar. `anchor` is the submenu's row (unknown on its
|
||||
/// first frame), `rows` the level's rows' height when known (the popup shrinks to it), and
|
||||
/// `frame` the popup's margins.
|
||||
pub fn level_room(screen: Rect, bar_bottom: Option<f32>, depth: usize, anchor: Option<Rect>, rows: Option<f32>, frame: f32) -> f32 {
|
||||
let top = bar_bottom.unwrap_or(screen.top()).max(screen.top()) + EDGE;
|
||||
let bottom = screen.bottom() - EDGE;
|
||||
let below_bar = bottom - top;
|
||||
let room = match anchor.filter(|_| depth > 1) {
|
||||
Some(row) => {
|
||||
// Downward from the row, or upward from it, whichever leaves more room. egui opens a
|
||||
// submenu upward only when the downward one doesn't fit the window (it hangs from the
|
||||
// row's top less half the frame margin).
|
||||
let (down, up) = (bottom - row.top(), row.bottom() - top);
|
||||
let opens_up = |h: f32| row.top() - (frame - 2.0) / 2.0 + h + frame > screen.bottom();
|
||||
let want = rows.unwrap_or(f32::INFINITY);
|
||||
if want > down && up > down && opens_up(want.min(up)) { up } else { down }.min(below_bar)
|
||||
}
|
||||
None => below_bar,
|
||||
} - frame;
|
||||
if room.is_finite() { room.max(4.0 * ARROW) } else { 4.0 * ARROW }
|
||||
}
|
||||
|
||||
/// Is the pointer over `rect` on this ui's layer (not under a submenu drawn on top)?
|
||||
fn pointer_on(ui: &Ui, rect: Rect) -> bool {
|
||||
ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id()))
|
||||
}
|
||||
|
||||
/// Draw one level of a menu, bounded by the window: when its rows don't fit the room below the
|
||||
/// menu bar they scroll, with scroll arrows at the top and bottom. `depth` is 1 for a top-level
|
||||
/// menu; `bar_bottom` is where the menu bar ends. The level's rect is registered as
|
||||
/// `menu-level:<depth>` for the control channel and tests.
|
||||
pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOnce(&mut Ui)) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let screen = ctx.content_rect();
|
||||
// The menu frame's margin and stroke around the rows.
|
||||
let frame = ui.spacing().menu_margin.sum().y + 2.0;
|
||||
let key = ui.id().with(("lc-menu-level", depth));
|
||||
// Rows' height from the last frame: does this level overflow?
|
||||
let content: Option<f32> = ctx.data(|d| d.get_temp(key));
|
||||
let room = level_room(screen, bar_bottom, depth, anchor(&ctx, depth), content, frame);
|
||||
let over = content.is_some_and(|h| h > room + 0.5);
|
||||
let up = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
|
||||
let height = if over { room - 2.0 * ARROW } else { room };
|
||||
// The popup's `Ui` is only as tall as the popup was last frame (egui's default area size on
|
||||
// the first), and a scroll area never grows past that. Ask for the room the window has;
|
||||
// auto-shrink then fits the area to its rows, so a menu scrolls only when they don't fit.
|
||||
let out = egui::ScrollArea::vertical()
|
||||
.id_salt(("menu-level", depth))
|
||||
.max_height(height)
|
||||
.min_scrolled_height(height)
|
||||
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
|
||||
.show(ui, rows);
|
||||
let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
|
||||
ctx.data_mut(|d| d.insert_temp(key, out.content_size.y));
|
||||
crate::widgets::register(&ctx, format!("menu-level:{depth}"), ui.min_rect());
|
||||
let (Some(up), Some(down)) = (up, down) else { return };
|
||||
let t = crate::theme::Tokens::get(&ctx);
|
||||
let x = ui.min_rect().x_range();
|
||||
let max = (out.content_size.y - out.inner_rect.height()).max(0.0);
|
||||
let offset = out.state.offset.y;
|
||||
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
|
||||
let mut target = offset;
|
||||
for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] {
|
||||
let strip = Rect::from_x_y_ranges(x, strip.y_range());
|
||||
let c = strip.center();
|
||||
let (h, w) = (4.5, 6.0);
|
||||
let tri = if dir < 0.0 {
|
||||
vec![pos2(c.x - w, c.y + h * 0.5), pos2(c.x + w, c.y + h * 0.5), pos2(c.x, c.y - h * 0.5)]
|
||||
} else {
|
||||
vec![pos2(c.x - w, c.y - h * 0.5), pos2(c.x + w, c.y - h * 0.5), pos2(c.x, c.y + h * 0.5)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri, if live { t.text } else { t.text_disabled }, egui::Stroke::NONE));
|
||||
let id = key.with(dir < 0.0);
|
||||
let r = ui.interact(strip, id, Sense::hover());
|
||||
r.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, live, crate::i18n::tr(if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }))
|
||||
});
|
||||
if live && pointer_on(ui, strip) {
|
||||
target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// The wheel scrolls over the arrows too.
|
||||
if pointer_on(ui, strip) {
|
||||
let wheel = ctx.input(|i| i.smooth_scroll_delta.y);
|
||||
if wheel != 0.0 {
|
||||
target = (target - wheel).clamp(0.0, max);
|
||||
}
|
||||
}
|
||||
}
|
||||
if target != offset {
|
||||
let mut st = out.state;
|
||||
st.offset.y = target;
|
||||
st.store(&ctx, out.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn level_room_keeps_every_level_below_the_bar() {
|
||||
let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1280.0, 703.0));
|
||||
let (bar, frame) = (Some(42.0), 14.0);
|
||||
let below = 703.0 - EDGE - (42.0 + EDGE) - frame;
|
||||
assert_eq!(level_room(screen, bar, 1, None, None, frame), below);
|
||||
// A submenu from a row near the top opens downward with the room under it…
|
||||
let row = Rect::from_min_size(pos2(100.0, 80.0), vec2(200.0, 30.0));
|
||||
assert_eq!(level_room(screen, bar, 2, Some(row), None, frame), 703.0 - EDGE - 80.0 - frame);
|
||||
// …and from a row near the bottom, upward to the bar.
|
||||
let low = Rect::from_min_size(pos2(100.0, 600.0), vec2(200.0, 30.0));
|
||||
assert_eq!(level_room(screen, bar, 2, Some(low), None, frame), 630.0 - (42.0 + EDGE) - frame);
|
||||
// A short submenu from a low row still fits downward: it keeps that room.
|
||||
assert_eq!(level_room(screen, bar, 2, Some(low), Some(50.0), frame), 703.0 - EDGE - 600.0 - frame);
|
||||
// Never more than the space under the bar, never less than the scroll arrows need.
|
||||
let odd = Rect::from_min_size(pos2(100.0, -50.0), vec2(200.0, 30.0));
|
||||
assert!(level_room(screen, bar, 2, Some(odd), None, frame) <= below);
|
||||
assert_eq!(level_room(Rect::from_min_size(egui::Pos2::ZERO, vec2(100.0, 40.0)), bar, 1, None, None, frame), 4.0 * ARROW);
|
||||
assert_eq!(level_room(screen, Some(f32::NAN), 1, None, None, frame), level_room(screen, None, 1, None, None, frame));
|
||||
assert_eq!(level_room(Rect::NOTHING, bar, 2, Some(row), None, frame), 4.0 * ARROW);
|
||||
// A top-level menu ignores a stale anchor.
|
||||
assert_eq!(level_room(screen, bar, 1, Some(low), None, frame), below);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anchor_is_read_on_the_next_frame_only() {
|
||||
let row = Rect::from_min_size(pos2(10.0, 20.0), vec2(200.0, 24.0));
|
||||
// set on pass 7: the submenu's room is measured from it on pass 8…
|
||||
assert_eq!(anchor_at(Some((row, 7)), 8), Some(row));
|
||||
// …not on the pass it was set (the row is drawn after its submenu), nor once the
|
||||
// submenu has closed and stopped refreshing it
|
||||
assert_eq!(anchor_at(Some((row, 7)), 7), None);
|
||||
assert_eq!(anchor_at(Some((row, 7)), 9), None);
|
||||
assert_eq!(anchor_at(None, 8), None);
|
||||
assert_eq!(anchor_at(Some((row, u64::MAX)), 0), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -746,22 +746,27 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
|
|||
let mut clicked: Option<(String, Value)> = None;
|
||||
let start = ui.cursor().left();
|
||||
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
|
||||
// Menus hang below the whole top bar and never grow past the window (#189): a tall menu
|
||||
// scrolls instead of egui sliding it up over the titles.
|
||||
let bar_bottom = Some(ui.max_rect().bottom());
|
||||
if total <= max_width {
|
||||
let saved = ui.spacing().item_spacing.x;
|
||||
ui.spacing_mut().item_spacing.x = TITLE_GAP;
|
||||
for (title, items) in &bar {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(title)).font(font.clone()).color(t.text_label)).frame(false));
|
||||
crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect);
|
||||
egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked));
|
||||
egui::Popup::menu(&r).show(|ui| crate::menu_level::level(ui, 1, bar_bottom, |ui| nodes_ui(ui, items, mac, &mut clicked, 1, bar_bottom)));
|
||||
}
|
||||
ui.spacing_mut().item_spacing.x = saved;
|
||||
} else {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Menu")).font(font.clone()).color(t.text_label)).frame(false));
|
||||
crate::widgets::register(ui.ctx(), "menu:all", r.rect);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for (title, items) in &bar {
|
||||
ui.menu_button(crate::i18n::tr(title), |ui| nodes_ui(ui, items, mac, &mut clicked));
|
||||
}
|
||||
crate::menu_level::level(ui, 1, bar_bottom, |ui| {
|
||||
for (title, items) in &bar {
|
||||
submenu(ui, title, crate::i18n::tr(title).to_string(), 1, bar_bottom, |ui| nodes_ui(ui, items, mac, &mut clicked, 2, bar_bottom));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some((id, params)) = clicked {
|
||||
|
|
@ -776,7 +781,19 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
|
|||
ui.cursor().left() - start
|
||||
}
|
||||
|
||||
fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Option<(String, Value)>) {
|
||||
/// A submenu row showing `text` at `depth`, whose rows `children` draws one level deeper,
|
||||
/// bounded like every level. The row is registered as `menusub:<label>` (the untranslated
|
||||
/// label), and while its submenu is open it is the anchor the next frame's room is measured from.
|
||||
fn submenu(ui: &mut egui::Ui, label: &str, text: String, depth: usize, bar_bottom: Option<f32>, children: impl FnOnce(&mut egui::Ui)) {
|
||||
let r = ui.menu_button(text, |ui| crate::menu_level::level(ui, depth + 1, bar_bottom, children));
|
||||
crate::widgets::register(ui.ctx(), format!("menusub:{label}"), r.response.rect);
|
||||
if r.inner.is_some() {
|
||||
crate::menu_level::set_anchor(ui.ctx(), depth + 1, r.response.rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// `depth` is 1 for a top-level menu's rows; `bar_bottom` is where the menu bar ends.
|
||||
fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Option<(String, Value)>, depth: usize, bar_bottom: Option<f32>) {
|
||||
ui.set_min_width(220.0);
|
||||
for n in nodes {
|
||||
match n {
|
||||
|
|
@ -784,7 +801,9 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti
|
|||
ui.separator();
|
||||
}
|
||||
MenuNode::Submenu { label, children } => {
|
||||
ui.menu_button(format!(" {}", crate::i18n::tr(label)), |ui| nodes_ui(ui, children, mac, clicked));
|
||||
submenu(ui, label, format!(" {}", crate::i18n::tr(label)), depth, bar_bottom, |ui| {
|
||||
nodes_ui(ui, children, mac, clicked, depth + 1, bar_bottom)
|
||||
});
|
||||
}
|
||||
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
|
||||
// a gutter for check marks, like native menus
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
//! #189: the in-window menu bar's menus and submenus stay below the bar. On a short window a
|
||||
//! tall menu (Photo) used to be slid up over the bar by egui, hiding the menu titles, and the
|
||||
//! pointer left on the title then opened the first row's submenu by itself.
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
const T: Duration = Duration::from_secs(5);
|
||||
const SETTLE: Duration = Duration::from_secs(10);
|
||||
|
||||
fn app(size: [f32; 2]) -> Headless {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, size, 1.0);
|
||||
h.settle(SETTLE);
|
||||
h
|
||||
}
|
||||
|
||||
fn widget(h: &Headless, id: &str) -> Option<egui::Rect> {
|
||||
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r)
|
||||
}
|
||||
|
||||
fn widgets_with(h: &Headless, prefix: &str) -> Vec<(String, egui::Rect)> {
|
||||
h.app.widgets.iter().filter(|(w, _)| w.starts_with(prefix)).cloned().collect()
|
||||
}
|
||||
|
||||
/// The open level `id` hangs below every menu title and ends inside the window.
|
||||
fn check_bar_clear(h: &Headless, id: &str, what: &str) {
|
||||
let level = widget(h, id).unwrap_or_else(|| panic!("{what}: no {id} on screen"));
|
||||
let titles = widgets_with(h, "menu:");
|
||||
assert!(!titles.is_empty(), "{what}: no menu titles");
|
||||
let bar_bottom = titles.iter().map(|(_, r)| r.bottom()).fold(f32::MIN, f32::max);
|
||||
assert!(level.top() >= bar_bottom - 0.5, "{what}: {id} {level:?} starts above the bar's bottom {bar_bottom}");
|
||||
assert!(level.bottom() <= h.size.y + 0.5, "{what}: {id} {level:?} runs past the window's bottom {}", h.size.y);
|
||||
for (title, r) in &titles {
|
||||
assert!(!level.intersects(*r), "{what}: {id} {level:?} covers {title} {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_menu_or_submenu_covers_the_menu_bar() {
|
||||
// The reporter's 1280 × 720 display less the title bar, and a shorter window where even a
|
||||
// scrolled menu's submenus must open upward.
|
||||
let sizes = [[1280.0, 703.0], [1160.0, 560.0]];
|
||||
let mut submenus = 0;
|
||||
for size in sizes {
|
||||
for top in ["Photo", "File", "View"] {
|
||||
let what = format!("{size:?} {top}");
|
||||
let mut h = app(size);
|
||||
assert!(widget(&h, "menu-level:1").is_none(), "{what}: a menu is open before any click");
|
||||
let r = h.request("ui.clickWidget", json!({"id": format!("menu:{top}")}), T);
|
||||
assert_eq!(r["ok"], true, "{what}: {r}");
|
||||
h.settle(SETTLE);
|
||||
check_bar_clear(&h, "menu-level:1", &what);
|
||||
// Hover each submenu row in view, top to bottom: rows low in the menu open upward.
|
||||
let level = widget(&h, "menu-level:1").unwrap_or_else(|| panic!("{what}: menu"));
|
||||
let rows: Vec<String> = widgets_with(&h, "menusub:").into_iter().filter(|(_, r)| level.contains_rect(*r)).map(|(id, _)| id).collect();
|
||||
for row in rows {
|
||||
let r = h.request("ui.hoverWidget", json!({"id": row}), T);
|
||||
assert_eq!(r["ok"], true, "{what}: {r}");
|
||||
h.settle(SETTLE);
|
||||
if widget(&h, "menu-level:2").is_none() {
|
||||
continue;
|
||||
}
|
||||
submenus += 1;
|
||||
check_bar_clear(&h, "menu-level:2", &format!("{what} › {row}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(submenus >= 6, "opened {submenus} submenus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tall_menu_scrolls_instead_of_growing_past_the_window() {
|
||||
let mut h = app([1160.0, 420.0]);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "menu:Photo"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
check_bar_clear(&h, "menu-level:1", "420 pt window");
|
||||
let level = widget(&h, "menu-level:1").expect("Photo menu");
|
||||
// Not every row fits: some are scrolled out of view, none drawn outside the menu.
|
||||
let rows = widgets_with(&h, "menusub:");
|
||||
assert!(!rows.is_empty(), "no submenu rows registered");
|
||||
assert!(rows.iter().any(|(_, r)| !level.contains_rect(*r)), "every row fits a 420 pt window: {rows:?}");
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF and Canon CR3 starting look
|
||||
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2 and Pentax PEF starting look
|
||||
|
||||
ARW, NEF, RW2, RAF and CR3 decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
|
||||
ARW, NEF, RW2, RAF, CR3, CR2 and PEF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
|
||||
|
||||
## Colour and tone are separate
|
||||
|
||||
|
|
@ -84,3 +84,10 @@ Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, he
|
|||
An embedded JPEG with neither ICC nor EXIF metadata uses Nikon maker-note `ColorSpace` (0x001e: 1 = sRGB, 2 = Adobe RGB). This fallback is applied before transfer decoding and resizing, and used by quick previews, preview-only RAW and the existing camera-look reference decode. A JPEG's own metadata takes precedence. No fitting algorithm, training split, highlight reconstruction or white-balance control changes are included. Render cache version 12 invalidates older previews.
|
||||
|
||||
Synthetic tests cover crop bounds/CFA preservation, colour-space tags, fallback transfer decoding and metadata precedence. A private Nikon Z 8 sample reports an 8256 × 5504 default crop from an 8280 × 5520 sensor. No private sample or Adobe asset is included.
|
||||
|
||||
### Canon CR2 and Pentax PEF
|
||||
|
||||
CR2 and PEF decode without a camera colour matrix too, but were left out of the fit until issue #310 (CR2 files opening
|
||||
flat and desaturated: the camera's preview first, then the neutral fallback). They now use the same fit, gates and
|
||||
relative white balance. This change was not measured on the corpus: the gates keep the fallback for any file whose fit
|
||||
doesn't pass, so a file can only stay as before or move closer to its camera JPEG. Per-model numbers are still to come.
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
|
||||
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
|
||||
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
|
||||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF and Canon CR3 get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
|
||||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2 and Pentax PEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
|
||||
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
|
||||
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
|
||||
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.
|
||||
|
||||
### RAW decoding
|
||||
- Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF
|
||||
and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.
|
||||
- Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG
|
||||
(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile
|
||||
Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the
|
||||
|
|
|
|||
Loading…
Reference in New Issue