Rework Camera Raw filter preview and navigation (#781)
* Add Camera Raw source-resolution preview navigation * Keep web preview responsive and discard stale refinement errors * Preserve Alt wheel intent throughout smoothed Camera Raw zoom * Specify refinement result type for the wasm fallback * Bound sparse Camera Raw proxy domains and validate detail tests * Document Camera Raw zoom controls with native screenshots * Drop the Camera Raw zoom screenshots; keep the guide as docs/camera-raw-zoom.md We don't commit sizable binary assets. Removes fit.png and actual-size.png, their LICENSE.txt and ATTRIBUTION row, and moves the controls guide next to docs/camera-raw-histogram.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Translate the Camera Raw full-resolution notice in de and pt-br Main added German and Brazilian Portuguese catalogues after this branch forked; the i18n coverage tests need the new label in both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c887ed94cc
commit
f9479063be
|
|
@ -0,0 +1,440 @@
|
|||
//! Full-resolution refinement of Camera Raw's interactive proxy. Native work is off the
|
||||
//! UI thread; only one revision runs at a time, and obsolete results never replace new edits.
|
||||
use egui::{Color32, ColorImage, Context, Pos2, Rect, TextureHandle, TextureOptions, pos2, vec2};
|
||||
use photocraft_algo::camera_raw::CameraRaw;
|
||||
use photocraft_geom::Rect as PixelRect;
|
||||
use photocraft_raster::Surface;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{Receiver, TryRecvError},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum Coverage {
|
||||
Selection(Surface),
|
||||
Mask(photocraft_doc::LayerMask),
|
||||
None,
|
||||
}
|
||||
|
||||
type DetailResult = Result<(u64, Surface), String>;
|
||||
type OverlayKey = (u64, bool, PixelRect, crate::camera_raw_scope_ui::ClippingMode, crate::theme::ThemeKind);
|
||||
|
||||
pub(crate) struct DetailPreview {
|
||||
source: Surface,
|
||||
area: PixelRect,
|
||||
coverage: Coverage,
|
||||
result: Option<(u64, Surface)>,
|
||||
pending: Option<(u64, Receiver<DetailResult>)>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
pub(crate) error: Option<String>,
|
||||
failed_revision: Option<u64>,
|
||||
crop: Option<(u64, bool, PixelRect)>,
|
||||
texture: Option<TextureHandle>,
|
||||
overlay_texture: Option<TextureHandle>,
|
||||
overlay_key: Option<OverlayKey>,
|
||||
pub(crate) ready: bool,
|
||||
}
|
||||
|
||||
impl Drop for DetailPreview {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl DetailPreview {
|
||||
pub(crate) fn new(source: Surface, area: PixelRect, coverage: Coverage) -> Self {
|
||||
Self {
|
||||
source,
|
||||
area,
|
||||
coverage,
|
||||
result: None,
|
||||
pending: None,
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
error: None,
|
||||
failed_revision: None,
|
||||
crop: None,
|
||||
texture: None,
|
||||
overlay_texture: None,
|
||||
overlay_key: None,
|
||||
ready: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pending(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
fn poll(&mut self, revision: u64) {
|
||||
let Some((requested_revision, rx)) = &self.pending else { return };
|
||||
let requested_revision = *requested_revision;
|
||||
let result = match rx.try_recv() {
|
||||
Ok(result) => result,
|
||||
Err(TryRecvError::Empty) => return,
|
||||
Err(TryRecvError::Disconnected) => Err("Camera Raw detail preview worker stopped".into()),
|
||||
};
|
||||
self.pending = None;
|
||||
match result {
|
||||
Ok((r, surface)) if r == revision => {
|
||||
self.result = Some((r, surface));
|
||||
self.crop = None;
|
||||
self.error = None;
|
||||
}
|
||||
Ok(_) => {} // A parameter changed while the worker was developing this revision.
|
||||
Err(e) => {
|
||||
if requested_revision == revision {
|
||||
self.error = Some(e);
|
||||
}
|
||||
self.failed_revision = Some(requested_revision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&mut self, ctx: &Context, revision: u64, params: &CameraRaw) {
|
||||
if self.pending.is_some() || self.result.as_ref().is_some_and(|(r, _)| *r == revision) || self.failed_revision == Some(revision) {
|
||||
return;
|
||||
}
|
||||
self.result = None;
|
||||
self.crop = None;
|
||||
self.error = None;
|
||||
// The engine's full-image pipeline needs multiple temporary float buffers. Never start
|
||||
// an unbounded allocation just because a huge sparse document was zoomed into.
|
||||
if self.area.width() as u64 * self.area.height() as u64 > 64_000_000 {
|
||||
self.error = Some("Full-resolution Camera Raw preview is limited to 64 megapixels; the proxy remains available".into());
|
||||
self.failed_revision = Some(revision);
|
||||
return;
|
||||
}
|
||||
let source = self.source.clone();
|
||||
let area = self.area;
|
||||
let coverage = self.coverage.clone();
|
||||
let mut params = params.clone();
|
||||
params.pixel_scale = 1.0;
|
||||
let cancel = self.cancel.clone();
|
||||
let ctx = ctx.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<DetailResult>();
|
||||
let work = move || {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| develop(&source, area, ¶ms, &coverage)))
|
||||
.map(|surface| (revision, surface))
|
||||
.map_err(|_| "Camera Raw detail preview failed; the proxy remains available".into());
|
||||
if !cancel.load(Ordering::Relaxed) {
|
||||
let _ = tx.send(result);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// A closed dialog can leave one non-interruptible engine stage finishing. Serialize
|
||||
// that work with the next dialog instead of multiplying its peak memory.
|
||||
static WORKER: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
match std::thread::Builder::new().name("camera-raw-preview".into()).spawn(move || {
|
||||
let _guard = WORKER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
work();
|
||||
}) {
|
||||
Ok(_) => self.pending = Some((revision, rx)),
|
||||
Err(e) => {
|
||||
self.error = Some(format!("Cannot start Camera Raw preview: {e}"));
|
||||
self.failed_revision = Some(revision);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// No browser worker/executor is available here. Preserve the interactive proxy
|
||||
// instead of blocking its UI with the full-image engine pipeline.
|
||||
drop(work);
|
||||
drop(rx);
|
||||
self.error = Some("Full-resolution filtered preview requires a native Camera Raw worker; the web preview keeps the proxy".into());
|
||||
self.failed_revision = Some(revision);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw only the visible pixel rectangle. No full-image GPU texture and no GPU-size limit
|
||||
/// on the source image; uploads are capped to the current viewport plus a one-pixel border.
|
||||
pub(crate) fn paint(&mut self, ui: &egui::Ui, image: Rect, revision: u64, before: bool, params: &CameraRaw, needed: bool) -> bool {
|
||||
self.ready = false;
|
||||
self.poll(revision);
|
||||
if !needed {
|
||||
return false;
|
||||
}
|
||||
let Some(crop) = visible_pixels(image, ui.clip_rect(), self.area, ui.input(|i| i.max_texture_side)) else { return false };
|
||||
let original = before || params.is_identity();
|
||||
if !original {
|
||||
self.request(ui.ctx(), revision, params);
|
||||
self.poll(revision);
|
||||
}
|
||||
let surface = if original {
|
||||
&self.source
|
||||
} else if let Some((r, surface)) = &self.result
|
||||
&& *r == revision
|
||||
{
|
||||
surface
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let key = (if original { 0 } else { revision }, original, crop);
|
||||
if self.crop != Some(key) {
|
||||
let color = crop_image(surface, crop);
|
||||
let options = TextureOptions { magnification: egui::TextureFilter::Nearest, ..TextureOptions::LINEAR };
|
||||
match &mut self.texture {
|
||||
Some(t) => t.set(color, options),
|
||||
None => self.texture = Some(ui.ctx().load_texture("cr-detail-preview", color, options)),
|
||||
}
|
||||
self.crop = Some(key);
|
||||
}
|
||||
if let Some(texture) = &self.texture {
|
||||
let scale = image.size() / vec2(self.area.width() as f32, self.area.height() as f32);
|
||||
let screen = Rect::from_min_size(
|
||||
image.min + vec2((crop.x0 - self.area.x0) as f32, (crop.y0 - self.area.y0) as f32) * scale,
|
||||
vec2(crop.width() as f32, crop.height() as f32) * scale,
|
||||
);
|
||||
ui.painter().image(texture.id(), screen, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
|
||||
self.ready = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn sample(&self, p: [f32; 2], revision: u64, before: bool, params: &CameraRaw) -> Option<[f32; 4]> {
|
||||
if !self.ready || !p.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) {
|
||||
return None;
|
||||
}
|
||||
let source = if before || params.is_identity() {
|
||||
&self.source
|
||||
} else if let Some((r, source)) = &self.result
|
||||
&& *r == revision
|
||||
{
|
||||
source
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let x = self.area.x0 + ((p[0] * self.area.width() as f32) as i32).min((self.area.width() - 1) as i32);
|
||||
let y = self.area.y0 + ((p[1] * self.area.height() as f32) as i32).min((self.area.height() - 1) as i32);
|
||||
Some(source.rgba(x, y))
|
||||
}
|
||||
|
||||
pub(crate) fn overlay(&mut self, ui: &egui::Ui, image: Rect, revision: u64, mode: crate::camera_raw_scope_ui::ClippingMode) -> bool {
|
||||
use crate::camera_raw_scope_ui::ClippingMode;
|
||||
if !self.ready {
|
||||
return false;
|
||||
}
|
||||
let Some((r, original, crop)) = self.crop else { return false };
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let key = (r, original, crop, mode, t.kind);
|
||||
if self.overlay_key != Some(key) {
|
||||
let surface = if original {
|
||||
&self.source
|
||||
} else if let Some((r, surface)) = &self.result
|
||||
&& *r == revision
|
||||
{
|
||||
surface
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let mut row = vec![[0.0; 4]; crop.width() as usize];
|
||||
let mut pixels = Vec::with_capacity(crop.width() as usize * crop.height() as usize);
|
||||
for y in crop.y0..crop.y1 {
|
||||
surface.read_rgba_into(PixelRect::new(crop.x0, y, crop.x1, y + 1), &mut row);
|
||||
pixels.extend(row.iter().map(|p| {
|
||||
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
|
||||
return Color32::TRANSPARENT;
|
||||
}
|
||||
let (lo, hi) = photocraft_algo::histogram::clipping(*p);
|
||||
match mode {
|
||||
ClippingMode::ShadowChannels => t.histogram_color(7 ^ lo),
|
||||
ClippingMode::HighlightChannels => {
|
||||
if hi == 0 {
|
||||
t.histogram_background()
|
||||
} else {
|
||||
t.histogram_color(hi)
|
||||
}
|
||||
}
|
||||
ClippingMode::Warnings(w) => {
|
||||
if w & 2 != 0 && hi != 0 {
|
||||
t.histogram_color(1)
|
||||
} else if w & 1 != 0 && lo != 0 {
|
||||
t.histogram_color(4)
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
}
|
||||
}
|
||||
ClippingMode::None => Color32::TRANSPARENT,
|
||||
}
|
||||
}));
|
||||
}
|
||||
let color = ColorImage::new([crop.width() as usize, crop.height() as usize], pixels);
|
||||
match &mut self.overlay_texture {
|
||||
Some(t) => t.set(color, TextureOptions::NEAREST),
|
||||
None => self.overlay_texture = Some(ui.ctx().load_texture("cr-detail-clipping", color, TextureOptions::NEAREST)),
|
||||
}
|
||||
self.overlay_key = Some(key);
|
||||
}
|
||||
if let Some(texture) = &self.overlay_texture {
|
||||
let scale = image.size() / vec2(self.area.width() as f32, self.area.height() as f32);
|
||||
let screen = Rect::from_min_size(
|
||||
image.min + vec2((crop.x0 - self.area.x0) as f32, (crop.y0 - self.area.y0) as f32) * scale,
|
||||
vec2(crop.width() as f32, crop.height() as f32) * scale,
|
||||
);
|
||||
ui.painter().image(texture.id(), screen, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn develop(source: &Surface, area: PixelRect, params: &CameraRaw, coverage: &Coverage) -> Surface {
|
||||
let mut result = photocraft_engine::lens_cmds::camera_raw_surface(source, area, params);
|
||||
if matches!(coverage, Coverage::None) {
|
||||
return result;
|
||||
}
|
||||
let width = area.width() as usize;
|
||||
let channels = source.channels();
|
||||
let mut original = Vec::new();
|
||||
let mut row = Vec::new();
|
||||
for y in area.y0..area.y1 {
|
||||
let rect = PixelRect::new(area.x0, y, area.x1, y + 1);
|
||||
source.read_region_into(rect, &mut original);
|
||||
result.read_region_into(rect, &mut row);
|
||||
for (x, (out, original)) in row.chunks_exact_mut(channels).zip(original.chunks_exact(channels)).take(width).enumerate() {
|
||||
let x = area.x0 + x as i32;
|
||||
let alpha = match coverage {
|
||||
Coverage::Selection(mask) => mask.sample_channel(x, y, 0),
|
||||
Coverage::Mask(mask) => mask.value(x, y),
|
||||
Coverage::None => 1.0,
|
||||
}
|
||||
.clamp(0.0, 1.0);
|
||||
for (out, original) in out.iter_mut().zip(original) {
|
||||
*out = *original + (*out - *original) * alpha;
|
||||
}
|
||||
}
|
||||
result.write_region(rect, &row);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn visible_pixels(image: Rect, viewport: Rect, area: PixelRect, max_side: usize) -> Option<PixelRect> {
|
||||
let visible = image.intersect(viewport);
|
||||
if !image.is_finite() || image.width() <= 0.0 || image.height() <= 0.0 || !visible.is_positive() || area.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let scale = vec2(area.width() as f32, area.height() as f32) / image.size();
|
||||
let lo = (visible.min - image.min) * scale;
|
||||
let hi = (visible.max - image.min) * scale;
|
||||
let crop = PixelRect::new(
|
||||
area.x0.saturating_add((lo.x.floor() as i32).saturating_sub(1)),
|
||||
area.y0.saturating_add((lo.y.floor() as i32).saturating_sub(1)),
|
||||
area.x0.saturating_add((hi.x.ceil() as i32).saturating_add(1)),
|
||||
area.y0.saturating_add((hi.y.ceil() as i32).saturating_add(1)),
|
||||
)
|
||||
.intersect(&area);
|
||||
// Proxy is sufficient below its native scale; a larger intermediate should never be
|
||||
// allocated if an unusually large viewport exceeds the device's texture limit.
|
||||
(crop.width() as usize <= max_side && crop.height() as usize <= max_side && crop.width() as u64 * crop.height() as u64 <= 16_777_216).then_some(crop)
|
||||
}
|
||||
|
||||
fn crop_image(surface: &Surface, area: PixelRect) -> ColorImage {
|
||||
let width = area.width() as usize;
|
||||
let mut row = vec![[0.0; 4]; width];
|
||||
let mut pixels = Vec::with_capacity(width * area.height() as usize);
|
||||
let enc = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
for y in area.y0..area.y1 {
|
||||
surface.read_rgba_into(PixelRect::new(area.x0, y, area.x1, y + 1), &mut row);
|
||||
pixels.extend(row.iter().map(|q| Color32::from_rgba_unmultiplied(enc(q[0]), enc(q[1]), enc(q[2]), enc(q[3]))));
|
||||
}
|
||||
ColorImage::new([width, area.height() as usize], pixels)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::PixelFormat;
|
||||
#[test]
|
||||
fn refinement_uses_the_engine_at_source_resolution_and_respects_selection() {
|
||||
let area = PixelRect::new(10, -5, 42, 19);
|
||||
let mut source = Surface::new(PixelFormat::RGBA8);
|
||||
let mut values = vec![0.0; 32 * 24 * 4];
|
||||
for (i, p) in values.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
p.copy_from_slice(&[if i % 2 == 0 { 0.1 } else { 0.8 }, 0.3, 0.6, 1.0]);
|
||||
}
|
||||
source.write_region(area, &values);
|
||||
let params = CameraRaw { exposure: 0.5, texture: 15.0, grain_amount: 30.0, ..Default::default() };
|
||||
let expected = photocraft_engine::lens_cmds::camera_raw_surface(&source, area, ¶ms);
|
||||
assert_eq!(develop(&source, area, ¶ms, &Coverage::None), expected);
|
||||
let mask = Surface::with_default(PixelFormat::GRAY8, &[0.0]);
|
||||
assert_eq!(develop(&source, area, ¶ms, &Coverage::Selection(mask)), source);
|
||||
}
|
||||
#[test]
|
||||
fn stale_refinements_and_failed_old_revisions_do_not_replace_new_settings() {
|
||||
let source = Surface::with_default(PixelFormat::RGBA8, &[0.2, 0.4, 0.6, 1.0]);
|
||||
let mut preview = DetailPreview::new(source.clone(), PixelRect::new(0, 0, 16, 16), Coverage::None);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
preview.pending = Some((1, rx));
|
||||
tx.send(Ok((1, source.clone()))).unwrap();
|
||||
preview.poll(2);
|
||||
assert!(preview.result.is_none());
|
||||
assert!(!preview.pending());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
preview.pending = Some((1, rx));
|
||||
tx.send(Err("old failure".into())).unwrap();
|
||||
preview.poll(2);
|
||||
assert_eq!(preview.failed_revision, Some(1), "a failure must not suppress the new revision");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
preview.pending = Some((2, rx));
|
||||
tx.send(Ok((2, source))).unwrap();
|
||||
preview.poll(2);
|
||||
assert_eq!(preview.result.as_ref().map(|(r, _)| *r), Some(2));
|
||||
assert!(preview.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_resolution_sampling_and_crops_keep_fine_detail_at_every_supported_depth() {
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
let format = PixelFormat { mode, sample: depth, alpha: true };
|
||||
let area = PixelRect::new(-12, 8, 28, 38);
|
||||
let mut source = Surface::new(format);
|
||||
let channels = format.channels();
|
||||
let mut pixels = vec![0.0; 40 * 30 * channels];
|
||||
for (i, pixel) in pixels.chunks_exact_mut(channels).enumerate() {
|
||||
pixel.fill(if i % 2 == 0 { 0.1 } else { 0.9 });
|
||||
pixel[channels - 1] = 1.0;
|
||||
}
|
||||
source.write_region(area, &pixels);
|
||||
let params = CameraRaw { exposure: 0.5, sharpen_amount: 20.0, grain_amount: 30.0, ..Default::default() };
|
||||
let full = develop(&source, area, ¶ms, &Coverage::None);
|
||||
let expected = photocraft_engine::lens_cmds::camera_raw_surface(&source, area, ¶ms);
|
||||
assert_eq!(full, expected);
|
||||
let mut preview = DetailPreview::new(source, area, Coverage::None);
|
||||
preview.result = Some((1, full));
|
||||
preview.ready = true;
|
||||
let dark = preview.sample([0.0, 0.0], 1, false, ¶ms).unwrap();
|
||||
let light = preview.sample([1.0 / 40.0, 0.0], 1, false, ¶ms).unwrap();
|
||||
assert!(light[0] - dark[0] > 0.5, "native alternating pixels survive: {mode:?}/{depth:?}");
|
||||
assert_eq!(crop_image(&expected, PixelRect::new(-12, 8, -10, 9)).size, [2, 1]);
|
||||
assert!(preview.sample([f32::NAN, 0.0], 1, false, ¶ms).is_none());
|
||||
assert!(preview.sample([0.5, 0.5], 2, false, ¶ms).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_sparse_images_keep_the_proxy_without_starting_an_unbounded_worker() {
|
||||
let source = Surface::new(PixelFormat::RGBA8);
|
||||
let mut preview = DetailPreview::new(source, PixelRect::new(0, 0, 8001, 8000), Coverage::None);
|
||||
preview.request(&Context::default(), 1, &CameraRaw { exposure: 1.0, ..Default::default() });
|
||||
assert!(!preview.pending());
|
||||
assert!(preview.error.as_deref().unwrap().contains("64 megapixels"));
|
||||
assert_eq!(preview.failed_revision, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_uploads_only_visible_pixels_even_on_a_huge_offset_image() {
|
||||
let area = PixelRect::new(-200, 10, 15800, 10010);
|
||||
let image = Rect::from_min_size(pos2(-7800.0, -4800.0), vec2(16000.0, 10000.0));
|
||||
let visible = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let crop = visible_pixels(image, visible, area, 4096).unwrap();
|
||||
assert_eq!((crop.width(), crop.height()), (802, 602));
|
||||
assert!(visible_pixels(image, Rect::from_min_size(Pos2::ZERO, vec2(8000.0, 6000.0)), area, 4096).is_none());
|
||||
assert!(visible_pixels(Rect::NOTHING, visible, area, 4096).is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
//! Camera Raw navigation in source-pixel coordinates. No document edits.
|
||||
use egui::{Key, Modifiers, PointerButton, Pos2, Rect, Response, Sense, Ui, Vec2, vec2};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{state::CameraRawPreviewState, widgets};
|
||||
|
||||
const MIN_ZOOM: f32 = 0.0001;
|
||||
const MAX_ZOOM: f32 = 16.0;
|
||||
|
||||
impl CameraRawPreviewState {
|
||||
pub(crate) fn scale(&self, viewport: Rect, size: Vec2) -> f32 {
|
||||
self.zoom
|
||||
.filter(|z| z.is_finite() && *z > 0.0)
|
||||
.map(|z| z.clamp(MIN_ZOOM, MAX_ZOOM))
|
||||
.unwrap_or_else(|| (viewport.width().max(1.0) / size.x.max(f32::EPSILON)).min(viewport.height().max(1.0) / size.y.max(f32::EPSILON)).min(MAX_ZOOM))
|
||||
}
|
||||
|
||||
pub(crate) fn image_rect(&mut self, viewport: Rect, size: Vec2) -> Rect {
|
||||
let extent = size.max(Vec2::splat(f32::EPSILON)) * self.scale(viewport, size);
|
||||
for (center, (image, visible)) in self.center.iter_mut().zip([(extent.x, viewport.width()), (extent.y, viewport.height())]) {
|
||||
let margin = (visible.max(1.0) / image.max(1.0) * 0.5).min(0.5);
|
||||
*center = if center.is_finite() { center.clamp(margin, 1.0 - margin) } else { 0.5 };
|
||||
}
|
||||
Rect::from_min_size(viewport.center() - vec2(self.center[0] * extent.x, self.center[1] * extent.y), extent)
|
||||
}
|
||||
|
||||
pub(crate) fn zoom_at(&mut self, zoom: f32, at: Pos2, viewport: Rect, size: Vec2) {
|
||||
if !zoom.is_finite() || !at.x.is_finite() || !at.y.is_finite() {
|
||||
return;
|
||||
}
|
||||
let old = self.image_rect(viewport, size);
|
||||
let point = (at - old.min) / old.size();
|
||||
self.zoom = Some(zoom.clamp(MIN_ZOOM, MAX_ZOOM));
|
||||
let extent = size.max(Vec2::splat(f32::EPSILON)) * self.scale(viewport, size);
|
||||
let center = point - (at - viewport.center()) / extent;
|
||||
self.center = [center.x, center.y];
|
||||
self.image_rect(viewport, size);
|
||||
}
|
||||
|
||||
pub(crate) fn pan(&mut self, delta: Vec2, viewport: Rect, size: Vec2) {
|
||||
if !delta.x.is_finite() || !delta.y.is_finite() {
|
||||
return;
|
||||
}
|
||||
let rect = self.image_rect(viewport, size);
|
||||
// Leaving fit mode on a hand drag would make later window resizes stop fitting.
|
||||
self.center[0] -= delta.x / rect.width();
|
||||
self.center[1] -= delta.y / rect.height();
|
||||
self.image_rect(viewport, size);
|
||||
}
|
||||
|
||||
pub(crate) fn fit(&mut self) {
|
||||
self.zoom = None;
|
||||
self.center = [0.5, 0.5];
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate with the rest of the Camera Raw request before changing anything.
|
||||
pub(crate) fn prepare(current: CameraRawPreviewState, value: &Value) -> Result<CameraRawPreviewState, String> {
|
||||
let obj = value.as_object().ok_or("Camera Raw view must be an object")?;
|
||||
let mut next = current;
|
||||
for (key, value) in obj {
|
||||
match key.as_str() {
|
||||
"zoom" if value.as_str() == Some("fit") => next.fit(),
|
||||
"zoom" => {
|
||||
let z = value
|
||||
.as_f64()
|
||||
.filter(|z| z.is_finite() && (MIN_ZOOM as f64..=MAX_ZOOM as f64).contains(z))
|
||||
.ok_or("Camera Raw zoom must be 'fit' or 0.0001–16 (1 = 100%)")?;
|
||||
next.zoom = Some(z as f32);
|
||||
}
|
||||
"center" => {
|
||||
let a = value.as_array().filter(|a| a.len() == 2).ok_or("Camera Raw center must be [u,v]")?;
|
||||
let coordinate = |i| {
|
||||
a.get(i)
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|v| v.is_finite() && (0.0..=1.0).contains(v))
|
||||
.map(|v| v as f32)
|
||||
.ok_or("Camera Raw center must be finite coordinates in 0–1")
|
||||
};
|
||||
next.center = [coordinate(0)?, coordinate(1)?];
|
||||
}
|
||||
"hand" => next.hand = value.as_bool().ok_or("Camera Raw hand must be a boolean")?,
|
||||
_ => return Err(format!("unknown Camera Raw view property {key}")),
|
||||
}
|
||||
}
|
||||
if obj.get("zoom").and_then(Value::as_str) == Some("fit") {
|
||||
next.fit();
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ZoomCapture {
|
||||
start: Pos2,
|
||||
image: Rect,
|
||||
box_zoom: bool,
|
||||
}
|
||||
|
||||
/// One capture shared with the colour sampler. Space/middle drag always pans; S takes
|
||||
/// precedence over the persistent Zoom/Hand tool, so a sampler click never zooms.
|
||||
pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: Rect, size: Vec2, sampler: &mut bool) -> (Rect, Response, bool, Option<Rect>) {
|
||||
let sampling = *sampler;
|
||||
let response = ui.interact(viewport, ui.id().with("cr-sample-preview"), Sense::click_and_drag());
|
||||
let modifiers = ui.input(|i| i.modifiers);
|
||||
let temporary_zoom = modifiers.command && (state.hand || sampling);
|
||||
let hand = !temporary_zoom && (state.hand && !sampling || ui.input(|i| i.key_down(Key::Space)));
|
||||
let zoom_tool = temporary_zoom || !hand && !sampling;
|
||||
let panning = hand || response.dragged_by(PointerButton::Middle);
|
||||
let at = response.hover_pos().unwrap_or(viewport.center());
|
||||
let rect = state.image_rect(viewport, size);
|
||||
// Reuse the canvas wheel policy: retain the modifiers of the wheel event while egui
|
||||
// smooths it, even if Alt is released before the final part of the notch arrives.
|
||||
let wheel = crate::wheel_nav::read(ui.ctx(), false);
|
||||
if response.hovered() {
|
||||
ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO);
|
||||
match wheel {
|
||||
Some(crate::wheel_nav::Wheel::Zoom(factor)) => state.zoom_at(state.scale(viewport, size) * factor, at, viewport, size),
|
||||
Some(crate::wheel_nav::Wheel::Pan(delta)) => state.pan(delta, viewport, size),
|
||||
None => {}
|
||||
}
|
||||
response.clone().on_hover_cursor(if hand {
|
||||
egui::CursorIcon::Grab
|
||||
} else if !zoom_tool {
|
||||
egui::CursorIcon::Crosshair
|
||||
} else if modifiers.alt {
|
||||
egui::CursorIcon::ZoomOut
|
||||
} else {
|
||||
egui::CursorIcon::ZoomIn
|
||||
});
|
||||
}
|
||||
let capture_id = response.id.with("zoom-capture");
|
||||
if response.drag_started_by(PointerButton::Primary) && zoom_tool {
|
||||
let start = ui.input(|i| i.pointer.press_origin()).unwrap_or(at);
|
||||
if rect.contains(start) {
|
||||
ui.ctx().data_mut(|data| data.insert_temp(capture_id, ZoomCapture { start, image: rect, box_zoom: modifiers.command && !temporary_zoom }));
|
||||
}
|
||||
}
|
||||
let capture = ui.ctx().data(|data| data.get_temp::<ZoomCapture>(capture_id));
|
||||
let mut zoom_box = None;
|
||||
if panning && (response.dragged_by(PointerButton::Primary) || response.dragged_by(PointerButton::Middle)) {
|
||||
state.pan(response.drag_delta(), viewport, size);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
} else if response.dragged_by(PointerButton::Primary)
|
||||
&& let Some(capture) = capture
|
||||
{
|
||||
if capture.box_zoom {
|
||||
if let Some(end) = response.interact_pointer_pos() {
|
||||
let region = Rect::from_two_pos(capture.start, end).intersect(viewport);
|
||||
zoom_box = Some(region);
|
||||
}
|
||||
} else {
|
||||
// The anchor is the initial press, not the moving pointer; reversing a scrub
|
||||
// follows the same detail instead of drifting across the image.
|
||||
state.zoom_at(state.scale(viewport, size) * (response.drag_delta().x * 0.01).clamp(-1.0, 1.0).exp(), capture.start, viewport, size);
|
||||
}
|
||||
} else if response.double_clicked() && hand {
|
||||
state.fit();
|
||||
} else if response.clicked() && zoom_tool && rect.contains(at) {
|
||||
if modifiers.command && modifiers.shift {
|
||||
state.zoom_at(1.0, at, viewport, size);
|
||||
} else if modifiers.alt {
|
||||
state.zoom_at(state.scale(viewport, size) / 1.25, at, viewport, size);
|
||||
} else if state.zoom.is_none() {
|
||||
state.zoom_at(1.0, at, viewport, size);
|
||||
} else {
|
||||
state.fit();
|
||||
}
|
||||
}
|
||||
if response.drag_stopped_by(PointerButton::Primary) {
|
||||
if let Some(capture) = capture.filter(|c| c.box_zoom)
|
||||
&& let Some(end) = response.interact_pointer_pos()
|
||||
{
|
||||
let region = Rect::from_two_pos(capture.start, end).intersect(viewport);
|
||||
if region.width() >= 4.0 && region.height() >= 4.0 {
|
||||
let point = (region.center() - capture.image.min) / capture.image.size();
|
||||
state.zoom = Some(
|
||||
(capture.image.width() / size.x * (viewport.width() / region.width()).min(viewport.height() / region.height())).clamp(MIN_ZOOM, MAX_ZOOM),
|
||||
);
|
||||
state.center = [point.x, point.y];
|
||||
}
|
||||
}
|
||||
ui.ctx().data_mut(|data| data.remove::<ZoomCapture>(capture_id));
|
||||
}
|
||||
response.context_menu(|ui| {
|
||||
if ui.button(tl!("Fit in View")).clicked() {
|
||||
state.fit();
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("100%")).clicked() {
|
||||
state.zoom_at(1.0, at, viewport, size);
|
||||
ui.close();
|
||||
}
|
||||
ui.menu_button(tl!("Zoom Tool"), |ui| {
|
||||
for percent in [25.0, 50.0, 100.0, 200.0, 400.0, 800.0, 1600.0] {
|
||||
if ui.button(format!("{percent:.0}%")).clicked() {
|
||||
state.zoom_at(percent / 100.0, at, viewport, size);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if !ui.ctx().text_edit_focused() {
|
||||
ui.input_mut(|i| {
|
||||
if i.consume_key(Modifiers::COMMAND | Modifiers::ALT, Key::Num0) {
|
||||
state.zoom_at(1.0, viewport.center(), viewport, size);
|
||||
} else if i.consume_key(Modifiers::COMMAND, Key::Num0) {
|
||||
state.fit();
|
||||
}
|
||||
if i.consume_key(Modifiers::COMMAND, Key::Plus) || i.consume_key(Modifiers::COMMAND, Key::Equals) {
|
||||
state.zoom_at(state.scale(viewport, size) * 1.25, viewport.center(), viewport, size);
|
||||
}
|
||||
if i.consume_key(Modifiers::COMMAND, Key::Minus) {
|
||||
state.zoom_at(state.scale(viewport, size) / 1.25, viewport.center(), viewport, size);
|
||||
}
|
||||
if i.consume_key(Modifiers::NONE, Key::H) {
|
||||
state.hand = true;
|
||||
*sampler = false;
|
||||
}
|
||||
if i.consume_key(Modifiers::NONE, Key::Z) {
|
||||
state.hand = false;
|
||||
*sampler = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
(state.image_rect(viewport, size), response, panning || temporary_zoom, zoom_box)
|
||||
}
|
||||
|
||||
pub(crate) fn toolbar(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: Rect, size: Vec2, sampler: &mut bool) {
|
||||
let zoom_tool = crate::icons::button(ui, "zoom-in", 28.0, !state.hand && !*sampler, tl!("Zoom Tool"));
|
||||
zoom_tool.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Zoom Tool")));
|
||||
if zoom_tool.double_clicked() {
|
||||
state.fit();
|
||||
}
|
||||
if zoom_tool.clicked() {
|
||||
state.hand = false;
|
||||
*sampler = false;
|
||||
}
|
||||
let hand_tool = crate::icons::button(ui, "hand", 28.0, state.hand && !*sampler, tl!("Hand Tool"));
|
||||
hand_tool.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Hand Tool")));
|
||||
if hand_tool.double_clicked() {
|
||||
state.fit();
|
||||
}
|
||||
if hand_tool.clicked() {
|
||||
state.hand = true;
|
||||
*sampler = false;
|
||||
}
|
||||
if widgets::secondary_button(ui, tl!("Fit in View"), 84.0).clicked() {
|
||||
state.fit();
|
||||
}
|
||||
if widgets::secondary_button(ui, tl!("100%"), 52.0).clicked() {
|
||||
state.zoom_at(1.0, viewport.center(), viewport, size);
|
||||
}
|
||||
let mut zoom = state.zoom;
|
||||
let mut options = vec![(None, tl!("Fit in View"))];
|
||||
let labels = ["25%", "50%", "100%", "200%", "400%", "800%", "1600%"];
|
||||
for (z, label) in [0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0].into_iter().zip(labels) {
|
||||
options.push((Some(z), label));
|
||||
}
|
||||
let current_label = format!("{:.1}%", state.scale(viewport, size) * 100.0);
|
||||
if !options.iter().any(|(z, _)| *z == zoom) {
|
||||
options.push((zoom, ¤t_label));
|
||||
}
|
||||
if widgets::dropdown(ui, "cr-preview-zoom", &mut zoom, &options, 80.0) {
|
||||
match zoom {
|
||||
Some(z) => state.zoom_at(z, viewport.center(), viewport, size),
|
||||
None => state.fit(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::pos2;
|
||||
#[test]
|
||||
fn zoom_preserves_the_point_under_the_pointer_and_pan_clamps() {
|
||||
let viewport = Rect::from_min_size(pos2(10.0, 20.0), vec2(800.0, 600.0));
|
||||
let size = vec2(1600.0, 1200.0);
|
||||
let mut view = CameraRawPreviewState::default();
|
||||
let at = pos2(310.0, 270.0);
|
||||
let initial = view.image_rect(viewport, size);
|
||||
let point = (at - initial.min) / initial.size();
|
||||
view.zoom_at(1.0, at, viewport, size);
|
||||
let enlarged = view.image_rect(viewport, size);
|
||||
assert!(((at - enlarged.min) / enlarged.size() - point).length() < 1e-5);
|
||||
view.pan(vec2(1e9, -1e9), viewport, size);
|
||||
let panned = view.image_rect(viewport, size);
|
||||
assert!(panned.contains_rect(viewport));
|
||||
view.fit();
|
||||
assert_eq!(view.image_rect(viewport, size), initial);
|
||||
}
|
||||
#[test]
|
||||
fn tiny_images_nonfinite_state_and_resize_stay_centered_and_finite() {
|
||||
let viewport = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0));
|
||||
let size = vec2(40.0, 20.0);
|
||||
let mut view = CameraRawPreviewState { zoom: Some(f32::NAN), center: [f32::INFINITY, f32::NAN], hand: true };
|
||||
assert!(view.image_rect(viewport, size).is_finite());
|
||||
view.zoom_at(1.0, pos2(100.0, 100.0), viewport, size);
|
||||
assert_eq!(view.image_rect(viewport, size).center(), viewport.center());
|
||||
let before = view;
|
||||
view.zoom_at(f32::INFINITY, viewport.center(), viewport, size);
|
||||
view.pan(vec2(f32::NAN, 1.0), viewport, size);
|
||||
assert_eq!(view, before);
|
||||
}
|
||||
}
|
||||
|
|
@ -100,6 +100,7 @@ pub(crate) struct ScopeView {
|
|||
pub(crate) ms: f64,
|
||||
pub(crate) revision: u64,
|
||||
pub(crate) overlay_tex: Option<TextureHandle>,
|
||||
pub(crate) overlay_detail: bool,
|
||||
pub(crate) overlay_key: Option<(u64, bool, ClippingMode, crate::theme::ThemeKind)>,
|
||||
pub(crate) floating_dragged: bool,
|
||||
pub(crate) geometry_requested: bool,
|
||||
|
|
@ -141,7 +142,10 @@ impl CameraRawDialog {
|
|||
}
|
||||
let x = ((p[0] * self.pw as f32) as usize).min(self.pw - 1);
|
||||
let y = ((p[1] * self.ph as f32) as usize).min(self.ph - 1);
|
||||
let q = *self.pixels().get(y.checked_mul(self.pw)?.checked_add(x)?)?;
|
||||
let q = self
|
||||
.detail
|
||||
.sample(p, self.preview_revision, self.show_before, &self.params)
|
||||
.or_else(|| self.pixels().get(y.checked_mul(self.pw)?.checked_add(x)?).copied())?;
|
||||
(q[3] > 0.0 && q.iter().all(|v| v.is_finite())).then_some(q)
|
||||
}
|
||||
pub(crate) fn readout(&self, p: [f32; 2], lab: bool) -> Option<Value> {
|
||||
|
|
@ -559,17 +563,16 @@ pub(crate) fn floating(ctx: &egui::Context, d: &mut CameraRawDialog, view: &mut
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeState, rect: Rect) {
|
||||
pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeState, rect: Rect, resp: &egui::Response, panning: bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let resp = ui.interact(rect, ui.id().with("cr-sample-preview"), Sense::click_and_drag());
|
||||
if let Some(p) = resp.hover_pos() {
|
||||
if let Some(p) = resp.hover_pos().filter(|p| rect.contains(*p)) {
|
||||
d.scope.pointer_sample =
|
||||
Some([((p.x - rect.left()) / rect.width().max(1.0)).clamp(0.0, 1.0), ((p.y - rect.top()) / rect.height().max(1.0)).clamp(0.0, 1.0)]);
|
||||
} else {
|
||||
d.scope.pointer_sample = None;
|
||||
}
|
||||
let to_screen = |p: [f32; 2]| pos2(rect.left() + p[0] * rect.width(), rect.top() + p[1] * rect.height());
|
||||
if view.sampler_tool {
|
||||
if view.sampler_tool && !panning {
|
||||
resp.clone().on_hover_cursor(egui::CursorIcon::Crosshair);
|
||||
if resp.clicked()
|
||||
&& let Some(p) = d.scope.pointer_sample
|
||||
|
|
@ -597,6 +600,9 @@ pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut Cam
|
|||
d.scope.sampler_drag = None;
|
||||
}
|
||||
}
|
||||
if panning {
|
||||
d.scope.sampler_drag = None;
|
||||
}
|
||||
let alt_zone = d.scope.tone_drag.map(|d| d.0).or(d.scope.alt_tone);
|
||||
let mode = if ui.input(|i| i.modifiers.alt && i.pointer.primary_down()) && alt_zone.is_some() {
|
||||
if matches!(alt_zone, Some(ToneZone::Blacks | ToneZone::Shadows)) { ClippingMode::ShadowChannels } else { ClippingMode::HighlightChannels }
|
||||
|
|
@ -606,8 +612,12 @@ pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut Cam
|
|||
ClippingMode::None
|
||||
};
|
||||
let key = (d.preview_revision, d.show_before, mode, t.kind);
|
||||
if mode != ClippingMode::None {
|
||||
if d.scope.overlay_key != Some(key) {
|
||||
if mode != ClippingMode::None && d.detail.overlay(ui, rect, d.preview_revision, mode) {
|
||||
d.scope.overlay_key = Some(key);
|
||||
d.scope.overlay_detail = true;
|
||||
} else if mode != ClippingMode::None {
|
||||
if d.scope.overlay_key != Some(key) || d.scope.overlay_detail {
|
||||
d.scope.overlay_detail = false;
|
||||
let colors = d
|
||||
.pixels()
|
||||
.iter()
|
||||
|
|
@ -649,9 +659,10 @@ pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut Cam
|
|||
ui.painter().image(tex.id(), rect, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
}
|
||||
let visible = rect.intersect(ui.clip_rect());
|
||||
if !view.samplers.is_empty() {
|
||||
ui.painter().rect_filled(
|
||||
Rect::from_min_size(rect.min, vec2(rect.width().min(330.0), view.samplers.len().min(MAX_SAMPLERS) as f32 * 16.0 + 12.0)),
|
||||
Rect::from_min_size(visible.min, vec2(visible.width().min(330.0), view.samplers.len().min(MAX_SAMPLERS) as f32 * 16.0 + 12.0)),
|
||||
t.radius_sm,
|
||||
t.dock.gamma_multiply(0.92),
|
||||
);
|
||||
|
|
@ -662,7 +673,7 @@ pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut Cam
|
|||
ui.painter().line_segment([q - vec2(8.0, 0.0), q + vec2(8.0, 0.0)], Stroke::new(1.0, t.text));
|
||||
ui.painter().line_segment([q - vec2(0.0, 8.0), q + vec2(0.0, 8.0)], Stroke::new(1.0, t.text));
|
||||
let text = format!("{} {}", i + 1, readout_text(d.readout(*p, view.lab)));
|
||||
ui.painter().text(pos2(rect.left() + 6.0, rect.top() + 6.0 + i as f32 * 16.0), Align2::LEFT_TOP, text, crate::theme::mono(11.0), t.text);
|
||||
ui.painter().text(pos2(visible.left() + 6.0, visible.top() + 6.0 + i as f32 * 16.0), Align2::LEFT_TOP, text, crate::theme::mono(11.0), t.text);
|
||||
ui.painter().text(q + vec2(7.0, 7.0), Align2::LEFT_TOP, format!("{}", i + 1), crate::theme::mono(11.0), t.text);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ pub struct CameraRawDialog {
|
|||
pub(crate) pw: usize,
|
||||
pub(crate) ph: usize,
|
||||
full_w: usize,
|
||||
full_h: usize,
|
||||
preview_ppp: f32,
|
||||
pub(crate) detail: super::camera_raw_detail_ui::DetailPreview,
|
||||
pub(crate) viewport: Option<ERect>,
|
||||
pub(crate) float: bool,
|
||||
tex: Option<TextureHandle>,
|
||||
before_tex: Option<TextureHandle>,
|
||||
|
|
@ -59,7 +63,7 @@ pub struct CameraRawDialog {
|
|||
}
|
||||
|
||||
impl CameraRawDialog {
|
||||
pub fn describe(&self, view: &crate::state::CameraRawScopeState) -> Value {
|
||||
pub fn describe(&self, view: &crate::state::CameraRawScopeState, navigation: &crate::state::CameraRawPreviewState) -> Value {
|
||||
let h = self.histogram();
|
||||
let mut result = json!({"layer": self.layer.0, "params": serde_json::to_value(&self.params).unwrap_or(Value::Null), "proxy": [self.pw, self.ph],
|
||||
"renderMs": self.render_ms, "histogramMs": self.histogram_ms, "previewRevision": self.preview_revision, "before": self.show_before,
|
||||
|
|
@ -70,6 +74,13 @@ impl CameraRawDialog {
|
|||
Target::SmartFilter(index) => Some(index),
|
||||
Target::NewFilter => None,
|
||||
});
|
||||
result["view"] = json!(navigation);
|
||||
result["sourceSize"] = json!([self.full_w, self.full_h]);
|
||||
result["previewApproximate"] = json!(!self.detail.ready && (self.pw != self.full_w || self.ph != self.full_h));
|
||||
result["detailPending"] = json!(self.detail.pending());
|
||||
result["detailError"] = json!(self.detail.error);
|
||||
result["viewportRect"] = json!(self.viewport.map(|r| [r.left(), r.top(), r.right(), r.bottom()]));
|
||||
result["zoom"] = json!(self.viewport.map(|r| navigation.scale(r, vec2(self.full_w as f32, self.full_h as f32) / self.preview_ppp)));
|
||||
result["scope"] = serde_json::to_value(view).unwrap_or(Value::Null);
|
||||
result["hasScopeSelection"] = json!(self.selection.is_some());
|
||||
result["vectorscope"] = json!(
|
||||
|
|
@ -204,9 +215,18 @@ fn open_pixels(
|
|||
let st = app.session.active().ok_or("no document")?;
|
||||
let canvas = st.doc.bounds();
|
||||
let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default();
|
||||
let area = surf.content_bounds().intersect(&canvas);
|
||||
let area = if area.is_empty() { canvas } else { area };
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
// Use the engine filter domain, including pixels outside the canvas: spatial Camera Raw
|
||||
// stages (vignette, grain, local contrast) must agree with the committed result.
|
||||
let area = canvas.union(&surf.content_bounds());
|
||||
let width = area.x1.checked_sub(area.x0).filter(|n| (1..=1_048_576).contains(n));
|
||||
let height = area.y1.checked_sub(area.y0).filter(|n| (1..=1_048_576).contains(n));
|
||||
let (Some(w), Some(h)) = (width, height) else {
|
||||
return Err("Camera Raw preview bounds are too large".into());
|
||||
};
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
if w.checked_mul(h).is_none_or(|pixels| pixels > 512_000_000) {
|
||||
return Err("Camera Raw preview bounds are too large".into());
|
||||
}
|
||||
let k = w.max(h).div_ceil(PROXY_SIDE).max(1);
|
||||
let (pw, ph) = (w.div_ceil(k), h.div_ceil(k));
|
||||
let mut proxy = vec![[0.0f32; 4]; pw * ph];
|
||||
|
|
@ -250,6 +270,7 @@ fn open_pixels(
|
|||
{
|
||||
app.ui.camera_raw_scope = view;
|
||||
}
|
||||
app.ui.camera_raw_preview = Default::default();
|
||||
app.ui.camera_raw_scope.samplers.clear();
|
||||
// Keep Camera Raw compact on open; vectorscope is an opt-in context-menu view.
|
||||
app.ui.camera_raw_scope.vectorscope = false;
|
||||
|
|
@ -276,6 +297,20 @@ fn open_pixels(
|
|||
pw,
|
||||
ph,
|
||||
full_w: w,
|
||||
full_h: h,
|
||||
preview_ppp: ctx.pixels_per_point(),
|
||||
detail: super::camera_raw_detail_ui::DetailPreview::new(
|
||||
surf,
|
||||
area,
|
||||
match coverage {
|
||||
Coverage::Selection => {
|
||||
st.doc.selection.clone().map(super::camera_raw_detail_ui::Coverage::Selection).unwrap_or(super::camera_raw_detail_ui::Coverage::None)
|
||||
}
|
||||
Coverage::Mask(mask) => super::camera_raw_detail_ui::Coverage::Mask(mask),
|
||||
Coverage::None => super::camera_raw_detail_ui::Coverage::None,
|
||||
},
|
||||
),
|
||||
viewport: None,
|
||||
float,
|
||||
tex: None,
|
||||
before_tex: None,
|
||||
|
|
@ -300,7 +335,9 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
|
|||
return Some(Err("Camera Raw params must be an object".into()));
|
||||
};
|
||||
if fields.is_empty() {
|
||||
return Some(open(app, ctx).map(|_| app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)).unwrap_or(Value::Null)));
|
||||
return Some(
|
||||
open(app, ctx).map(|_| app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap_or(Value::Null)),
|
||||
);
|
||||
}
|
||||
if let Some(sf) = params.get("smartFilter") {
|
||||
let opened = (|| {
|
||||
|
|
@ -310,7 +347,7 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
|
|||
let layer = sf.get("layer").and_then(Value::as_u64).ok_or("smartFilter.layer must be a layer id")?;
|
||||
let index = sf.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or("smartFilter.index must be a filter index")?;
|
||||
open_smart_filter(app, ctx, LayerId(layer), index)?;
|
||||
Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)).unwrap_or(Value::Null))
|
||||
Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap_or(Value::Null))
|
||||
})();
|
||||
return Some(opened);
|
||||
}
|
||||
|
|
@ -320,7 +357,7 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
|
|||
};
|
||||
for (key, value) in fields {
|
||||
match key.as_str() {
|
||||
"set" | "scope" => {}
|
||||
"set" | "scope" | "view" => {}
|
||||
"before" | "commit" | "cancel" if value.is_boolean() => {}
|
||||
"before" | "commit" | "cancel" => return Some(Err(format!("Camera Raw {key} must be a boolean"))),
|
||||
_ => return Some(Err(format!("unknown Camera Raw ui property {key}"))),
|
||||
|
|
@ -331,6 +368,7 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
|
|||
}
|
||||
let was_closed = app.camera_raw.is_none();
|
||||
let previous_view = app.ui.camera_raw_scope.clone();
|
||||
let previous_navigation = app.ui.camera_raw_preview;
|
||||
if was_closed && let Err(e) = open(app, ctx) {
|
||||
return Some(Err(e));
|
||||
}
|
||||
|
|
@ -338,6 +376,7 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
|
|||
if was_closed && result.as_ref().is_some_and(Result::is_err) {
|
||||
app.camera_raw = None;
|
||||
app.ui.camera_raw_scope = previous_view;
|
||||
app.ui.camera_raw_preview = previous_navigation;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
|
@ -375,7 +414,17 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti
|
|||
},
|
||||
None => None,
|
||||
};
|
||||
let navigation = match ui.get("view") {
|
||||
Some(view) => match super::camera_raw_preview_ui::prepare(app.ui.camera_raw_preview, view) {
|
||||
Ok(next) => Some(next),
|
||||
Err(e) => return Some(Err(e)),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let d = app.camera_raw.as_mut()?;
|
||||
if let Some(view) = navigation {
|
||||
app.ui.camera_raw_preview = view;
|
||||
}
|
||||
if d.params.point_curve != next_params.point_curve {
|
||||
d.curve_state = Default::default();
|
||||
}
|
||||
|
|
@ -403,7 +452,7 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti
|
|||
if ui.get("commit").and_then(Value::as_bool) == Some(true) {
|
||||
return Some(commit(app));
|
||||
}
|
||||
Some(Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)).unwrap_or(Value::Null)))
|
||||
Some(Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap_or(Value::Null)))
|
||||
}
|
||||
|
||||
fn commit(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
|
|
@ -537,17 +586,45 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
let footer_h = 48.0;
|
||||
let body = ERect::from_min_max(pos2(full.left(), title.bottom()), pos2(full.right(), full.bottom() - footer_h));
|
||||
let scope = &mut app.ui.camera_raw_scope;
|
||||
// Preview.
|
||||
let view = ERect::from_min_max(body.min, pos2(body.right() - PANEL_W, body.bottom())).shrink(16.0);
|
||||
painter.rect_filled(view, 0.0, t.canvas);
|
||||
// Clip both pixels and scope overlays before painting the settings dock/footer.
|
||||
let preview_body = ERect::from_min_max(body.min, pos2((body.right() - PANEL_W).max(body.left() + 1.0), body.bottom()));
|
||||
let view = ERect::from_min_max(
|
||||
preview_body.min + vec2(16.0, 16.0),
|
||||
pos2((preview_body.right() - 16.0).max(preview_body.left() + 17.0), (preview_body.bottom() - 48.0).max(preview_body.top() + 17.0)),
|
||||
);
|
||||
d.viewport = Some(view);
|
||||
d.preview_ppp = ctx.pixels_per_point();
|
||||
let size = vec2(d.full_w as f32, d.full_h as f32) / ctx.pixels_per_point();
|
||||
let navigation = &mut app.ui.camera_raw_preview;
|
||||
let mut preview = ui.new_child(egui::UiBuilder::new().max_rect(view));
|
||||
preview.set_clip_rect(view.intersect(ui.clip_rect()));
|
||||
preview.painter().rect_filled(view, 0.0, t.canvas);
|
||||
let (r, response, panning, zoom_box) = super::camera_raw_preview_ui::interact(&mut preview, navigation, view, size, &mut scope.sampler_tool);
|
||||
let tex = if d.show_before { d.before_tex.as_ref() } else { d.tex.as_ref() };
|
||||
if let Some(tex) = tex {
|
||||
let s = (view.width() / d.pw as f32).min(view.height() / d.ph as f32);
|
||||
let r = ERect::from_center_size(view.center(), vec2(d.pw as f32 * s, d.ph as f32 * s));
|
||||
widgets::checker(&painter, r, 8.0);
|
||||
painter.image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
// Never tile a checker across the offscreen extent of a highly zoomed image.
|
||||
widgets::checker(preview.painter(), r.intersect(view), 8.0);
|
||||
preview.painter().image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let detail_needed = r.width() * ctx.pixels_per_point() > d.pw as f32 || r.height() * ctx.pixels_per_point() > d.ph as f32;
|
||||
d.detail.paint(&preview, r, d.preview_revision, d.show_before, &d.params, detail_needed);
|
||||
if d.detail.pending() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
d.scope.preview_rect = Some(r);
|
||||
super::camera_raw_scope_ui::preview(ui, d, scope, r);
|
||||
super::camera_raw_scope_ui::preview(&mut preview, d, scope, r, &response, panning);
|
||||
}
|
||||
if let Some(region) = zoom_box {
|
||||
preview.painter().rect_stroke(region, 0.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
let toolbar = ERect::from_min_max(pos2(preview_body.left() + 16.0, view.bottom() + 8.0), preview_body.right_bottom() - vec2(16.0, 4.0));
|
||||
let mut nav_ui = ui.new_child(egui::UiBuilder::new().max_rect(toolbar).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
nav_ui.set_clip_rect(preview_body.intersect(ui.clip_rect()));
|
||||
super::camera_raw_preview_ui::toolbar(&mut nav_ui, navigation, view, size, &mut scope.sampler_tool);
|
||||
if d.detail.pending() {
|
||||
nav_ui.spinner();
|
||||
}
|
||||
if let Some(error) = &d.detail.error {
|
||||
nav_ui.label(egui::RichText::new(tl!("Full-resolution preview unavailable")).color(t.text_faint)).on_hover_text(error);
|
||||
}
|
||||
// Panels.
|
||||
let right = ERect::from_min_max(pos2(body.right() - PANEL_W, body.top()), body.max);
|
||||
|
|
@ -799,6 +876,20 @@ mod tests {
|
|||
assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"smartFilter": {"layer": "x"}})).unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distant_sparse_pixels_cannot_trigger_an_unbounded_proxy_scan() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width":64,"height":48})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
let layer = app.session.active().unwrap().active_layer.unwrap();
|
||||
let mut source = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8);
|
||||
source.write_region(Rect::new(30_000, 30_000, 30_001, 30_001), &[0.5, 0.5, 0.5, 1.0]);
|
||||
let result = open_pixels(&mut app, &ctx, layer, source, CameraRaw::default(), Coverage::None, Target::NewFilter);
|
||||
assert!(result.unwrap_err().contains("bounds are too large"));
|
||||
assert!(app.camera_raw.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_drives_the_engine() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
|
|
|
|||
|
|
@ -640,7 +640,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
|
|||
"brush": {"size": app.session.tools.brush.size, "hardness": app.session.tools.brush.hardness, "opacity": app.session.tools.brush.opacity},
|
||||
"distort": app.distort.describe(),
|
||||
"jobs": crate::jobs_ui::inspect(app),
|
||||
"cameraRaw": app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)),
|
||||
"cameraRaw": app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2065,6 +2065,7 @@ cameraRaw Color Barvy
|
|||
Patch: Záplata:
|
||||
Lasso around an area, then drag the selection Obtáhněte oblast lasem a pak přetáhněte výběr
|
||||
Edit Type Upravit text
|
||||
Full-resolution preview unavailable Náhled v plném rozlišení není dostupný
|
||||
No patterns Žádné vzorky
|
||||
Enable {name} Povolit {name}
|
||||
Hold Alt while drawing for straight segments Podržením Alt při kreslení vytvoříte rovné úseky
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2124,3 +2124,4 @@
|
|||
Don't show again Nicht mehr anzeigen
|
||||
Save failed: {error} Speichern fehlgeschlagen: {error}
|
||||
Save cancelled Speichern abgebrochen
|
||||
Full-resolution preview unavailable Vorschau in voller Auflösung nicht verfügbar
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2140,6 +2140,7 @@ cameraRaw Amount Cantidad
|
|||
Patch: Parche:
|
||||
Lasso around an area, then drag the selection Rodee un área con el lazo y arrastre la selección
|
||||
Edit Type Editar texto
|
||||
Full-resolution preview unavailable Vista previa a resolución completa no disponible
|
||||
No patterns Sin motivos
|
||||
Enable {name} Activar {name}
|
||||
Hold Alt while drawing for straight segments Mantenga Alt al dibujar para segmentos rectos
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
|
|
@ -2500,6 +2500,7 @@ cameraRaw Blending Fusion
|
|||
5 x 5 5 × 5
|
||||
Grid: Grille :
|
||||
Edit Type Modifier le texte
|
||||
Full-resolution preview unavailable Aperçu en pleine résolution indisponible
|
||||
No patterns Aucun motif
|
||||
Enable {name} Activer {name}
|
||||
Hold Alt while drawing for straight segments Maintenez Alt en dessinant pour des segments droits
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2487,6 +2487,7 @@ cameraRaw Blending Pembauran
|
|||
5 x 5 5 × 5
|
||||
Grid: Kisi-kisi:
|
||||
Edit Type Edit Teks
|
||||
Full-resolution preview unavailable Pratinjau resolusi penuh tidak tersedia
|
||||
No patterns Tidak ada pola
|
||||
Enable {name} Aktifkan {name}
|
||||
Hold Alt while drawing for straight segments Tahan Alt saat menggambar untuk segmen lurus
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
|
|
@ -2048,6 +2048,7 @@ cameraRaw Color カラー
|
|||
Patch: パッチ:
|
||||
Lasso around an area, then drag the selection 投げ縄で範囲を囲み、選択範囲をドラッグします
|
||||
Edit Type 文字を編集
|
||||
Full-resolution preview unavailable フル解像度のプレビューを利用できません
|
||||
No patterns パターンなし
|
||||
Enable {name} {name}を有効にする
|
||||
Hold Alt while drawing for straight segments 描画中に Alt を押すと直線セグメントになります
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2034,6 +2034,7 @@ cameraRaw Color 색상
|
|||
175% 175%
|
||||
250% 250%
|
||||
300% 300%
|
||||
Full-resolution preview unavailable 전체 해상도 미리보기를 사용할 수 없습니다
|
||||
|
||||
# Additional generated filter labels and Photoshop-aligned UI terminology.
|
||||
@id select.all 모두 선택
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 10.
|
|
|
@ -2523,3 +2523,4 @@ cameraRaw Whites Brancos
|
|||
Don't show again Não mostrar novamente
|
||||
Save failed: {error} Falha ao salvar: {error}
|
||||
Save cancelled Salvamento cancelado
|
||||
Full-resolution preview unavailable Pré-visualização em resolução total indisponível
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2343,6 +2343,7 @@ cameraRaw Roughness Неровность
|
|||
Patch: Заплатка:
|
||||
Lasso around an area, then drag the selection Обведите область лассо, затем перетащите выделение
|
||||
Edit Type Редактировать текст
|
||||
Full-resolution preview unavailable Полноразмерный предпросмотр недоступен
|
||||
No patterns Нет узоров
|
||||
Enable {name} Включить {name}
|
||||
Hold Alt while drawing for straight segments Удерживайте Alt при рисовании для прямых отрезков
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2074,6 +2074,7 @@ cameraRaw Blending 混合
|
|||
Details 详细信息
|
||||
Rendering mode 渲染模式
|
||||
Edit Type 编辑文字
|
||||
Full-resolution preview unavailable 全分辨率预览不可用
|
||||
No patterns 无图案
|
||||
Enable {name} 启用{name}
|
||||
Hold Alt while drawing for straight segments 绘制中按住 Alt 可逐点连接直线
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 14.
|
|
|
@ -2043,6 +2043,7 @@ cameraRaw Blending 混合
|
|||
Patch: 修補:
|
||||
Lasso around an area, then drag the selection 用套索圈出區域,然後拖曳選取範圍
|
||||
Edit Type 編輯文字
|
||||
Full-resolution preview unavailable 無法使用完整解析度預覽
|
||||
No patterns 無圖樣
|
||||
Enable {name} 啟用{name}
|
||||
Hold Alt while drawing for straight segments 繪製中按住 Alt 可逐點連接直線
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -27,6 +27,8 @@ pub mod brush_preview;
|
|||
pub mod brush_resize;
|
||||
pub mod brush_sections;
|
||||
pub mod brushes_tab;
|
||||
mod camera_raw_detail_ui;
|
||||
mod camera_raw_preview_ui;
|
||||
mod camera_raw_scope_ui;
|
||||
pub mod camera_raw_ui;
|
||||
pub mod canvas;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,22 @@ pub struct CameraRawScopeState {
|
|||
pub floating_rect: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// Camera Raw navigation, separate from filter settings. Reset for each opened image.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct CameraRawPreviewState {
|
||||
/// None fits the image; Some is physical display pixels per source pixel.
|
||||
pub zoom: Option<f32>,
|
||||
pub center: [f32; 2],
|
||||
pub hand: bool,
|
||||
}
|
||||
|
||||
impl Default for CameraRawPreviewState {
|
||||
fn default() -> Self {
|
||||
Self { zoom: None, center: [0.5, 0.5], hand: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Tool {
|
||||
Move,
|
||||
|
|
@ -782,6 +798,8 @@ pub struct UiState {
|
|||
pub chrome: crate::chrome_ui::ChromeState,
|
||||
#[serde(default)]
|
||||
pub camera_raw_scope: CameraRawScopeState,
|
||||
#[serde(default)]
|
||||
pub camera_raw_preview: CameraRawPreviewState,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
|
|
@ -838,6 +856,7 @@ impl Default for UiState {
|
|||
gpu_fallback_notice: None,
|
||||
chrome: Default::default(),
|
||||
camera_raw_scope: Default::default(),
|
||||
camera_raw_preview: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
//! Camera Raw navigation uses the real modal shell and never changes document history.
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use photocraft_ui_egui::{PhotocraftApp, camera_raw_ui, control, theme::ThemeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn fixture(ppp: f32) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).with_pixels_per_point(ppp).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":1200,"height":900})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color":"#808080"})).unwrap();
|
||||
camera_raw_ui::open(&mut app, &cc.egui_ctx).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
fn inspect(h: &Harness<'_, PhotocraftApp>) -> Value {
|
||||
control::inspect(h.state(), &h.ctx)["cameraRaw"].clone()
|
||||
}
|
||||
fn rectangle(h: &Harness<'_, PhotocraftApp>, key: &str) -> Rect {
|
||||
let v = inspect(h);
|
||||
let a = v[key].as_array().unwrap();
|
||||
Rect::from_min_max(pos2(a[0].as_f64().unwrap() as f32, a[1].as_f64().unwrap() as f32), pos2(a[2].as_f64().unwrap() as f32, a[3].as_f64().unwrap() as f32))
|
||||
}
|
||||
fn menu(h: &mut Harness<'_, PhotocraftApp>, ui: Value) -> Result<Value, String> {
|
||||
let ctx = h.ctx.clone();
|
||||
camera_raw_ui::menu(h.state_mut(), &ctx, "filter.cameraRaw", &json!({"ui":ui})).unwrap()
|
||||
}
|
||||
fn press(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, down: bool, modifiers: Modifiers) {
|
||||
h.event(Event::ModifiersChanged(modifiers));
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: down, modifiers });
|
||||
h.run_steps(2);
|
||||
}
|
||||
fn drag(h: &mut Harness<'_, PhotocraftApp>, start: Pos2, end: Pos2, modifiers: Modifiers) {
|
||||
press(h, start, true, modifiers);
|
||||
for n in 1..=4 {
|
||||
h.event(Event::PointerMoved(start + (end - start) * n as f32 / 4.0));
|
||||
h.run_steps(2);
|
||||
}
|
||||
press(h, end, false, modifiers);
|
||||
}
|
||||
fn key(h: &mut Harness<'_, PhotocraftApp>, key: Key, modifiers: Modifiers, down: bool) {
|
||||
h.event(Event::ModifiersChanged(modifiers));
|
||||
h.event(Event::Key { key, physical_key: None, pressed: down, repeat: false, modifiers });
|
||||
h.run_steps(2);
|
||||
}
|
||||
fn zoom(h: &Harness<'_, PhotocraftApp>) -> f64 {
|
||||
inspect(h)["zoom"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_toggles_fit_and_actual_pixels_at_both_display_scales_without_editing() {
|
||||
for ppp in [1.0, 2.0] {
|
||||
let mut h = fixture(ppp);
|
||||
let initial = inspect(&h);
|
||||
let history = h.state().session.active().unwrap().history.past_len();
|
||||
let revision = initial["previewRevision"].clone();
|
||||
let at = rectangle(&h, "viewportRect").center();
|
||||
press(&mut h, at, true, Modifiers::NONE);
|
||||
press(&mut h, at, false, Modifiers::NONE);
|
||||
assert_eq!(zoom(&h), 1.0);
|
||||
assert!((rectangle(&h, "previewRect").width() * ppp - 1200.0).abs() < 0.01, "100% maps one source pixel to one display pixel");
|
||||
assert_eq!(inspect(&h)["previewApproximate"], false);
|
||||
// Separate clicks so egui does not treat them as a double click.
|
||||
h.run_steps(30);
|
||||
press(&mut h, at, true, Modifiers::NONE);
|
||||
press(&mut h, at, false, Modifiers::NONE);
|
||||
assert_eq!(inspect(&h)["view"]["zoom"], Value::Null);
|
||||
assert_eq!(inspect(&h)["previewRevision"], revision);
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrub_zoom_and_alt_wheel_preserve_the_detail_under_the_initial_pointer() {
|
||||
let mut h = fixture(1.0);
|
||||
menu(&mut h, json!({"view":{"zoom":2.0}})).unwrap();
|
||||
h.run_steps(2);
|
||||
let at = rectangle(&h, "viewportRect").center() + vec2(35.0, 20.0);
|
||||
let image = rectangle(&h, "previewRect");
|
||||
let point = (at - image.min) / image.size();
|
||||
drag(&mut h, at, at + vec2(60.0, 0.0), Modifiers::NONE);
|
||||
assert!(zoom(&h) > 2.5, "rightward scrub increases zoom");
|
||||
let image = rectangle(&h, "previewRect");
|
||||
assert!(((at - image.min) / image.size() - point).length() < 0.002);
|
||||
let previous = zoom(&h);
|
||||
drag(&mut h, at, at - vec2(60.0, 0.0), Modifiers::NONE);
|
||||
assert!(zoom(&h) < previous, "leftward scrub decreases zoom");
|
||||
let old = zoom(&h);
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.event(Event::ModifiersChanged(Modifiers::ALT));
|
||||
h.event(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 100.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::ALT });
|
||||
// Release Alt while the wheel notch is still being smoothed: no residual pan.
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(10);
|
||||
assert!(zoom(&h) > old);
|
||||
let image = rectangle(&h, "previewRect");
|
||||
assert!(((at - image.min) / image.size() - point).length() < 0.002);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_pans_while_sampling_without_moving_probes_or_editing_the_filter() {
|
||||
let mut h = fixture(1.0);
|
||||
menu(&mut h, json!({"view":{"zoom":2.0},"scope":{"samplerTool":true,"samplers":[[0.5,0.5]]}})).unwrap();
|
||||
h.run_steps(2);
|
||||
let initial = inspect(&h);
|
||||
let at = rectangle(&h, "viewportRect").center();
|
||||
key(&mut h, Key::Space, Modifiers::NONE, true);
|
||||
drag(&mut h, at, at + vec2(80.0, 40.0), Modifiers::NONE);
|
||||
key(&mut h, Key::Space, Modifiers::NONE, false);
|
||||
assert_ne!(inspect(&h)["view"]["center"], initial["view"]["center"]);
|
||||
assert_eq!(inspect(&h)["scope"]["samplers"], initial["scope"]["samplers"]);
|
||||
assert_eq!(inspect(&h)["params"], initial["params"]);
|
||||
assert_eq!(inspect(&h)["previewRevision"], initial["previewRevision"]);
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.run_steps(2);
|
||||
let image = rectangle(&h, "previewRect");
|
||||
let point = (at - image.min) / image.size();
|
||||
let sample = inspect(&h)["pointerReadout"]["position"].clone();
|
||||
assert!((sample[0].as_f64().unwrap() - point.x as f64).abs() < 0.001);
|
||||
assert!((sample[1].as_f64().unwrap() - point.y as f64).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_shortcuts_and_box_zoom_use_the_same_path_for_control_and_command_keys() {
|
||||
for modifiers in [Modifiers { ctrl: true, command: true, ..Modifiers::NONE }, Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }] {
|
||||
let mut h = fixture(1.0);
|
||||
let at = rectangle(&h, "viewportRect").center();
|
||||
drag(&mut h, at - vec2(90.0, 60.0), at + vec2(90.0, 60.0), modifiers);
|
||||
assert!(zoom(&h) > 1.5, "command-drag fits the selected region");
|
||||
let old = zoom(&h);
|
||||
key(&mut h, Key::Minus, modifiers, true);
|
||||
key(&mut h, Key::Minus, modifiers, false);
|
||||
assert!(zoom(&h) < old);
|
||||
key(&mut h, Key::Num0, modifiers, true);
|
||||
key(&mut h, Key::Num0, modifiers, false);
|
||||
assert_eq!(inspect(&h)["view"]["zoom"], Value::Null);
|
||||
key(&mut h, Key::Num0, modifiers | Modifiers::ALT, true);
|
||||
key(&mut h, Key::Num0, modifiers | Modifiers::ALT, false);
|
||||
assert_eq!(zoom(&h), 1.0);
|
||||
menu(&mut h, json!({"view":{"zoom":2.0},"scope":{"samplerTool":true}})).unwrap();
|
||||
key(&mut h, Key::H, Modifiers::NONE, true);
|
||||
key(&mut h, Key::H, Modifiers::NONE, false);
|
||||
assert_eq!(inspect(&h)["scope"]["samplerTool"], false);
|
||||
assert_eq!(inspect(&h)["view"]["hand"], true);
|
||||
drag(&mut h, at, at + vec2(60.0, 0.0), modifiers);
|
||||
assert!(zoom(&h) > 2.5, "command temporarily zooms while Hand is selected");
|
||||
key(&mut h, Key::Z, Modifiers::NONE, true);
|
||||
key(&mut h, Key::Z, Modifiers::NONE, false);
|
||||
press(&mut h, at, true, modifiers | Modifiers::SHIFT);
|
||||
press(&mut h, at, false, modifiers | Modifiers::SHIFT);
|
||||
assert_eq!(zoom(&h), 1.0, "command-shift-click returns to actual pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_view_requests_are_atomic_and_reopening_starts_in_fit_view() {
|
||||
let mut h = fixture(1.0);
|
||||
let before = inspect(&h);
|
||||
for view in [
|
||||
json!(null),
|
||||
json!({"zoom":0}),
|
||||
json!({"zoom":100}),
|
||||
json!({"zoom":"100%"}),
|
||||
json!({"center":[-1,0]}),
|
||||
json!({"center":[0]}),
|
||||
json!({"hand":1}),
|
||||
json!({"unknown":true}),
|
||||
] {
|
||||
assert!(menu(&mut h, json!({"view":view,"set":{"exposure":1.0},"before":true})).is_err());
|
||||
assert_eq!(inspect(&h), before);
|
||||
}
|
||||
menu(&mut h, json!({"view":{"zoom":2.0,"hand":true}})).unwrap();
|
||||
menu(&mut h, json!({"cancel":true})).unwrap();
|
||||
menu(&mut h, json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(inspect(&h)["view"], json!({"zoom":null,"center":[0.5,0.5],"hand":false}));
|
||||
h.get_by_label(photocraft_ui_egui::i18n::tr(photocraft_ui_egui::i18n::current(), "100%")).click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(zoom(&h), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asynchronous_refinement_completes_for_the_latest_edit_and_before_keeps_navigation() {
|
||||
let mut h = fixture(1.0);
|
||||
menu(&mut h, json!({"view":{"zoom":1.0},"set":{"exposure":0.5},"scope":{"samplers":[[0.5,0.5]]}})).unwrap();
|
||||
h.run_steps(2);
|
||||
// Changing parameters while refinement may still be running must never display an old edit.
|
||||
menu(&mut h, json!({"set":{"exposure":1.0}})).unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
while inspect(&h)["previewApproximate"] != false && std::time::Instant::now() < deadline {
|
||||
h.run_steps(1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(inspect(&h)["detailError"], Value::Null);
|
||||
assert_eq!(inspect(&h)["detailPending"], false);
|
||||
assert_eq!(inspect(&h)["previewApproximate"], false);
|
||||
let developed = inspect(&h)["samplerReadouts"][0]["values"][0].as_f64().unwrap();
|
||||
let view = inspect(&h)["view"].clone();
|
||||
menu(&mut h, json!({"before":true})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(inspect(&h)["view"], view);
|
||||
assert_eq!(inspect(&h)["previewApproximate"], false);
|
||||
let original = inspect(&h)["samplerReadouts"][0]["values"][0].as_f64().unwrap();
|
||||
assert!(developed > original + 20.0);
|
||||
menu(&mut h, json!({"before":false})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(inspect(&h)["samplerReadouts"][0]["values"][0].as_f64().unwrap(), developed);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Camera Raw preview navigation
|
||||
|
||||
The Camera Raw dialog's preview has Zoom, Hand, Fit in View, 100%, and zoom preset
|
||||
controls at the bottom left.
|
||||
|
||||
Choose **Fit in View**, or press **Ctrl+0** on Windows/Linux (**Command+0** on macOS),
|
||||
to fit the whole image inside the preview.
|
||||
|
||||
At **100%**, one source pixel maps to one physical display pixel, including HiDPI.
|
||||
The native preview refines the processed source in the background so texture,
|
||||
sharpening, and grain can be inspected beyond the reduced proxy's resolution.
|
||||
|
||||
## Controls
|
||||
|
||||
| Action | Windows / Linux | macOS |
|
||||
|---|---|---|
|
||||
| Select Zoom | Z | Z |
|
||||
| Scrub zoom in / out | Left-drag right / left with Zoom | Same |
|
||||
| Toggle Fit / 100% | Click with Zoom | Same |
|
||||
| Zoom a selected region | Ctrl+drag | Command+drag |
|
||||
| Select Hand | H | H |
|
||||
| Temporary pan | Space+drag, or middle-button drag | Same |
|
||||
| Pointer-anchored zoom | Alt+wheel, or pinch | Option+wheel, or pinch |
|
||||
| Zoom in / out | Ctrl+plus / minus | Command+plus / minus |
|
||||
| Fit in View | Ctrl+0 | Command+0 |
|
||||
| Actual size | Ctrl+Alt+0 | Command+Option+0 |
|
||||
| Zoom presets | Bottom-left dropdown or right-click menu | Same |
|
||||
|
||||
Zoom and pan are view state: they do not change filter parameters or document
|
||||
history. Before/After keeps the same position; colour probes and clipping overlays
|
||||
follow the image coordinates. Cancel discards the filter; OK applies it through
|
||||
the existing Camera Raw command.
|
||||
|
||||
## Preview limits
|
||||
|
||||
The fast proxy remains available while native refinement is pending. Processed
|
||||
full-resolution refinement is limited to 64 megapixels because the existing
|
||||
engine allocates full-image temporary buffers. Larger sources and failed
|
||||
refinement retain the proxy with a notification. Only the visible crop is uploaded
|
||||
to the GPU (at most 16,777,216 pixels and the device texture-size limit).
|
||||
The initial preview domain is bounded to 512 megapixels, including sparse content
|
||||
outside the canvas. Histogram and vectorscope statistics remain proxy-based.
|
||||
WebAssembly retains the processed proxy until a browser worker path is available.
|
||||
|
||||
Gesture references: [Adobe Camera Raw introduction](https://helpx.adobe.com/camera-raw/desktop/get-started/overview-and-setup/introduction-camera-raw.html)
|
||||
and [Julieanne Kost's Camera Raw shortcuts](https://jkost.com/blog/2022/10/225-shortcuts-tips-and-tricks-for-adobe-camera-raw.html).
|
||||
|
|
@ -306,7 +306,7 @@ a driver process crash is detected by the startup marker on the next launch.
|
|||
RGB/Grayscale layer. `params: {"smartFilter": {"layer": id, "index": i}}` opens it on an existing
|
||||
Camera Raw smart filter instead (as double-clicking the filter in the Layers panel does): the
|
||||
stored settings over the pixels below that filter, previewed through the filter mask; commit
|
||||
then runs `layer.smartFilter.setParams` with every setting. `params.ui` accepts `set` (filter settings), `before`, `scope`, `commit` and
|
||||
then runs `layer.smartFilter.setParams` with every setting. `params.ui` accepts `set` (filter settings), `before`, `scope`, `view`, `commit` and
|
||||
`cancel`. All parts of one request are validated before any is applied; a rejected request
|
||||
leaves the settings, view state, preferences and document unchanged (and closes a dialog it
|
||||
opened). Unknown `ui` or settings properties, non-boolean `before` / `commit` / `cancel`, and
|
||||
|
|
@ -339,6 +339,38 @@ The response and `ui.inspect.cameraRaw` contain:
|
|||
- `hoverSample`: `position`, `rgb` (histogram domain) and `hueSaturation` (turns, 0–1; only
|
||||
while the vectorscope is shown), or null.
|
||||
- `hoveredZone`, `previewRect`, `scopeRect`, `vectorscopeRect`.
|
||||
- `view` (`zoom: null` for fit, or a numeric factor where 1 = 100%, `center: [u,v]`,
|
||||
`hand`), `zoom` (effective factor, null before layout), `sourceSize`, `viewportRect`.
|
||||
At 100%, one source pixel maps to one physical display pixel, including on HiDPI displays.
|
||||
- `previewApproximate`, `detailPending`, `detailError`: whether the visible image still uses
|
||||
the proxy, whether full-resolution refinement is running, and any refinement error.
|
||||
|
||||
`params.ui.view` changes navigation only: `zoom: "fit" | 0.0001..=16`, `center: [u,v]`
|
||||
(finite coordinates in 0..=1, clamped to keep the image within view), `hand: bool`. Fit recentres
|
||||
and follows window resizing; navigation resets when opening a different Camera Raw dialog.
|
||||
Invalid mixed requests are atomic, just like scope and filter settings. View requests do not
|
||||
re-develop the proxy or change filter parameters/history.
|
||||
|
||||
With the Zoom tool (Z), click toggles Fit / 100%; click-drag right/left scrubs in/out.
|
||||
Ctrl-drag (Command on macOS) fits a rectangular region. H selects Hand; Space temporarily pans,
|
||||
including while placing colour samplers. Alt/Option + wheel zooms at the pointer; an unmodified
|
||||
wheel pans. Pinch zooms. Ctrl/Command +/- zoom, 0 fits, and Alt/Option + Ctrl/Command + 0 shows
|
||||
100%; Ctrl/Command+Shift-click also selects 100%. Right-click opens Fit / 100% / preset
|
||||
percentages. Tool double-clicks fit the image.
|
||||
Text fields retain their keys and wheel gestures over the settings panel still scroll it.
|
||||
|
||||
The proxy remains the fast path while changing settings. Where more source pixels are needed,
|
||||
Camera Raw lazily refines with the **same engine Camera Raw pipeline** at pixelScale=1 and the
|
||||
same selection/filter-mask coverage. Native refinement runs off the UI thread (one revision at
|
||||
a time); stale results are discarded. Only a bounded visible crop is uploaded to the GPU, and
|
||||
pan/zoom reuse developed pixels. Before and neutral settings read the original pixels directly.
|
||||
Absurd proxy domains (over 512 MP, including distant sparse off-canvas pixels) are rejected
|
||||
before reading pixels. Full-resolution refinement is capped at 64 MP to bound the existing full-image engine pipeline;
|
||||
above that it explicitly reports unavailability and keeps the proxy. Oversized viewport crops
|
||||
also retain the proxy. Without a browser worker, wasm explicitly retains the filtered proxy
|
||||
rather than block its UI; Before and neutral settings still crop the original source pixels.
|
||||
The histogram and vectorscope continue to analyse the bounded proxy; readouts and clipping
|
||||
warnings use the full-resolution pixels once refinement is visible.
|
||||
|
||||
`params.ui.scope` changes presentation only: `shadows` / `highlights` (clipping warnings, U / O),
|
||||
`lab`, `samplerTool` (S), `vectorscope`, `selectedRegion` (requires a document selection),
|
||||
|
|
|
|||
Loading…
Reference in New Issue