mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-18 13:16:54 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da8b42400b | ||
|
|
19febc65c3 |
No files matched your search
@@ -228,6 +228,113 @@ fn main() {
|
||||
v
|
||||
});
|
||||
}
|
||||
// Image › Adjustments dialog preview, per settings change (#77). Before: the command re-run
|
||||
// on a downsampled proxy (plus its flatten for upload). After: the document with a temporary
|
||||
// clipped adjustment layer, recomposited over the target's area by the canvas compositor.
|
||||
for selection in [false, true] {
|
||||
if selection {
|
||||
exec(&mut s, "select.rect", json!({"x": w / 4, "y": h / 4, "width": w / 2, "height": h / 2, "ellipse": true}));
|
||||
}
|
||||
let sel = if selection { ", selection" } else { "" };
|
||||
let mut v = 0;
|
||||
b.time(&format!("Curves dialog change, CPU proxy preview{sel}"), |_| {
|
||||
v = (v + 7) % 120;
|
||||
let st = s.active().expect("doc");
|
||||
let k = photocraft_ui_egui::proxy::factor(&st.doc);
|
||||
let t = Instant::now();
|
||||
let p = photocraft_ui_egui::filter_dialog::preview_document(
|
||||
&st.doc,
|
||||
st.active_layer,
|
||||
"image.adjustments.curves",
|
||||
&json!({"points": [[0, 0], [100, 100 + v], [255, 255]]}),
|
||||
k,
|
||||
)
|
||||
.expect("preview");
|
||||
std::hint::black_box(photocraft_compose::flatten(&p));
|
||||
ms(t)
|
||||
});
|
||||
// Once per dialog session (the app caches both per document revision).
|
||||
let (base, region) = {
|
||||
let st = s.active().expect("doc");
|
||||
let target = st.active_layer.expect("layer");
|
||||
let t = Instant::now();
|
||||
let base = photocraft_ui_egui::adjust_preview::base_document(&st.doc, target).expect("base");
|
||||
let region = photocraft_ui_egui::adjust_preview::region(&st.doc, target);
|
||||
println!(" (layer preview session setup: {:.1} ms, region {region:?})", ms(t));
|
||||
(base, region)
|
||||
};
|
||||
b.time(&format!("Curves dialog change, layer preview{sel}"), |b| {
|
||||
v = (v + 7) % 120;
|
||||
let t = Instant::now();
|
||||
let p =
|
||||
photocraft_ui_egui::adjust_preview::with_settings(&base, "curves", &json!({"points": [[0, 0], [100, 100 + v], [255, 255]]})).expect("preview");
|
||||
let build = ms(t);
|
||||
let t = Instant::now();
|
||||
match &mut b.gpu {
|
||||
Some(g) => {
|
||||
if g.comp.render(&g.device, &g.queue, &p, region, |_, _| {}).is_err() {
|
||||
std::hint::black_box(photocraft_compose::render(&p, region));
|
||||
}
|
||||
let _ = g.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
}
|
||||
None => {
|
||||
std::hint::black_box(photocraft_compose::render(&p, region));
|
||||
}
|
||||
}
|
||||
build + ms(t)
|
||||
});
|
||||
// Zoomed in, the canvas composites the region's visible part (here a 2560×1440 view at
|
||||
// 100 %) and catches up elsewhere when the view moves.
|
||||
let view = Rect::from_xywh(w as i32 / 3, h as i32 / 3, 2560, 1440).intersect(®ion);
|
||||
b.time(&format!("Curves dialog change, layer preview 100 % view{sel}"), |b| {
|
||||
v = (v + 7) % 120;
|
||||
let t = Instant::now();
|
||||
let p =
|
||||
photocraft_ui_egui::adjust_preview::with_settings(&base, "curves", &json!({"points": [[0, 0], [100, 100 + v], [255, 255]]})).expect("preview");
|
||||
match &mut b.gpu {
|
||||
Some(g) => {
|
||||
if g.comp.render(&g.device, &g.queue, &p, view, |_, _| {}).is_err() {
|
||||
std::hint::black_box(photocraft_compose::render(&p, view));
|
||||
}
|
||||
let _ = g.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
}
|
||||
None => {
|
||||
std::hint::black_box(photocraft_compose::render(&p, view));
|
||||
}
|
||||
}
|
||||
ms(t)
|
||||
});
|
||||
// Zoomed out (fit to a 1600-pixel-wide view on a 2× display: k = 2 for 36 MP), the canvas
|
||||
// previews on a reduced copy built once per session.
|
||||
let zoom = 1600.0 / w as f32 * 2.0;
|
||||
let k = photocraft_ui_egui::adjust_preview::proxy_factor(zoom);
|
||||
let t = Instant::now();
|
||||
let proxy = photocraft_ui_egui::adjust_preview::proxy_base(&base, k);
|
||||
println!(" (zoomed-out proxy k {k} setup: {:.1} ms)", ms(t));
|
||||
let kk = k as i32;
|
||||
let pregion = Rect::new(region.x0 / kk, region.y0 / kk, region.x1 / kk + 1, region.y1 / kk + 1).intersect(&proxy.bounds());
|
||||
b.time(&format!("Curves dialog change, layer preview zoomed out{sel}"), |b| {
|
||||
v = (v + 7) % 120;
|
||||
let t = Instant::now();
|
||||
let p = photocraft_ui_egui::adjust_preview::proxy_with_settings(&proxy, "curves", &json!({"points": [[0, 0], [100, 100 + v], [255, 255]]}))
|
||||
.expect("preview");
|
||||
match &mut b.gpu {
|
||||
Some(g) => {
|
||||
if g.comp.render(&g.device, &g.queue, &p, pregion, |_, _| {}).is_err() {
|
||||
std::hint::black_box(photocraft_compose::render(&p, pregion));
|
||||
}
|
||||
let _ = g.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
}
|
||||
None => {
|
||||
std::hint::black_box(photocraft_compose::render(&p, pregion));
|
||||
}
|
||||
}
|
||||
ms(t)
|
||||
});
|
||||
if selection {
|
||||
exec(&mut s, "select.deselect", json!({}));
|
||||
}
|
||||
}
|
||||
exec(&mut s, "layer.newAdjustmentLayer.levels", json!({"inBlack": 10, "inWhite": 240, "gamma": 1.1}));
|
||||
let mut g = 1.0;
|
||||
b.time("Levels layer tweak (gamma) + full refresh", |b| {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
//!
|
||||
//! The dialog's fields are the command's complete parameter set (so automation can read and set
|
||||
//! them with `ui.dialog.set`) plus private `__` keys. While it is open the canvas previews the
|
||||
//! *real command* on the proxy document through the filter-preview machinery (`__filter` +
|
||||
//! `__preview`); OK runs the command once (one history step) and Cancel drops the preview, leaving
|
||||
//! the document untouched.
|
||||
//! settings as a temporary adjustment layer clipped to the target (`adjust_preview`, composited on
|
||||
//! the GPU); where that can't match the command, the *real command* runs on the proxy document
|
||||
//! through the filter-preview machinery (`__filter` + `__preview`). OK runs the command once (one
|
||||
//! history step) and Cancel drops the preview, leaving the document untouched.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
//! Live canvas preview for the Image › Adjustments dialogs (`adjust_dialog`).
|
||||
//!
|
||||
//! While a dialog is open on a pixel layer the canvas shows the committed document with a
|
||||
//! temporary adjustment layer (the dialog's settings, the selection as its mask) clipped to the
|
||||
//! target layer. That composite equals what the destructive command will produce (see the tests:
|
||||
//! within 1/255 for every kind, depth and mode below), but it is drawn by the regular canvas path,
|
||||
//! so on the GPU canvas a change re-runs the wgpu compositor instead of the command on a CPU
|
||||
//! proxy (the layer textures stay resident; just the adjustment's uniforms / LUT change):
|
||||
//! - over the target's area within the selection only (`switch_region`), and of that only what
|
||||
//! the view shows; the rest catches up as the view moves (`uncovered`);
|
||||
//! - zoomed out on a large document, on a copy reduced to about the screen's resolution, built
|
||||
//! once per session ([`gpu_proxy`]).
|
||||
//!
|
||||
//! OK still runs the real command (one history step); Cancel just drops the preview. The base
|
||||
//! document (with the preview layer) and the region are built once per dialog session; a change
|
||||
//! only swaps the layer's adjustment in a shallow copy.
|
||||
//!
|
||||
//! Cases where an adjustment-layer composite differs from the destructive result, or where it
|
||||
//! would not be cheap, keep the CPU proxy preview (`canvas::ensure_filter_preview`), see
|
||||
//! [`unsupported`]:
|
||||
//! - the target is not a plain pixel layer (smart object, type, shape, group: the command edits
|
||||
//! a smart object's filters or refuses), or is itself clipped (a clipped adjustment would act on
|
||||
//! the whole clipping group, not just the target);
|
||||
//! - the target uses Blend If (its "This Layer" ranges test the layer's adjusted colour);
|
||||
//! - the target's untouched area is visible (a surface without alpha whose content doesn't cover
|
||||
//! the canvas: the command only edits content);
|
||||
//! - the command targets an alpha channel, the Quick Mask or a single colour channel;
|
||||
//! - CMYK, Lab and the other non-RGB modes except Grayscale: the command converts each pixel to
|
||||
//! RGB, adjusts and converts back (CMYK even changes when the settings are neutral), which a
|
||||
//! composite in display RGB can't reproduce;
|
||||
//! - in Grayscale documents, kinds that make colour from gray (Vibrance, Color Balance, Black &
|
||||
//! White, Photo Filter, Channel Mixer, Gradient Map, colorized Hue/Saturation): the command
|
||||
//! folds each pixel back to gray, the composite would carry the colour through;
|
||||
//! - on the GPU canvas, documents the wgpu compositor doesn't cover (it would fall back to a
|
||||
//! full CPU composite per change); on the CPU canvas, documents above the proxy size.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_doc::{Adjustment, DocId, Document, Layer, LayerContent, LayerId, LayerMask};
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// Id of the temporary preview layer. Fixed so the GPU compositor keeps its mask (the selection)
|
||||
/// resident from one change to the next. Never reaches a committed document.
|
||||
pub const PREVIEW_LAYER: LayerId = LayerId(u64::MAX - 77);
|
||||
|
||||
/// Tag of the preview keys handed to the canvas (`canvas::display_doc`).
|
||||
const TAG: u64 = 1 << 59;
|
||||
/// Bits of a preview key that identify the session (document state + target); below them the
|
||||
/// settings hash.
|
||||
const SESSION_SHIFT: u32 = 40;
|
||||
|
||||
/// Why `target` in `doc` can't preview as a clipped adjustment layer, or None when it can.
|
||||
pub fn unsupported(doc: &Document, target: LayerId) -> Option<&'static str> {
|
||||
if !matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale) {
|
||||
return Some("colour mode composites through display RGB");
|
||||
}
|
||||
let Some(l) = doc.layer(target) else { return Some("no target layer") };
|
||||
let LayerContent::Raster(s) = &l.content else { return Some("not a pixel layer") };
|
||||
if l.clipped {
|
||||
return Some("the target is clipped");
|
||||
}
|
||||
if photocraft_compose::blend_if_active(l, doc.mode) {
|
||||
return Some("Blend If");
|
||||
}
|
||||
let shows_default = !s.format().alpha || s.default_pixel().last().is_some_and(|a| *a > 0.0);
|
||||
if shows_default && !s.content_bounds().contains_rect(&doc.bounds()) {
|
||||
return Some("untouched area is visible");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `adj` maps gray to gray. Others make colour in a grayscale document, which the command
|
||||
/// folds back to gray per pixel while the composite carries it to the end.
|
||||
fn keeps_gray(adj: &Adjustment) -> bool {
|
||||
match adj {
|
||||
Adjustment::BrightnessContrast { .. }
|
||||
| Adjustment::Levels { .. }
|
||||
| Adjustment::Curves { .. }
|
||||
| Adjustment::Exposure { .. }
|
||||
| Adjustment::Posterize { .. }
|
||||
| Adjustment::Threshold { .. }
|
||||
| Adjustment::Invert => true,
|
||||
Adjustment::HueSaturation { colorize, .. } => !colorize,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` with a placeholder temporary adjustment layer clipped to `target` (the selection as its
|
||||
/// mask): built once per dialog session, then [`with_settings`] per change. Errors when the target
|
||||
/// isn't eligible ([`unsupported`]).
|
||||
pub fn base_document(doc: &Document, target: LayerId) -> Result<Document, String> {
|
||||
if let Some(why) = unsupported(doc, target) {
|
||||
return Err(why.into());
|
||||
}
|
||||
let mut l = Layer::new("Adjustment Preview", LayerContent::Adjustment(Adjustment::Invert));
|
||||
l.id = PREVIEW_LAYER;
|
||||
l.clipped = true;
|
||||
if let Some(sel) = &doc.selection {
|
||||
l.mask = Some(LayerMask { surface: sel.clone(), enabled: true, linked: false, density: 1.0, feather: 0.0 });
|
||||
}
|
||||
let mut out = doc.clone();
|
||||
out.insert_above(Some(target), l);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A [`base_document`] with the dialog's settings in its preview layer (a shallow copy: pixel
|
||||
/// tiles are shared). Errors on invalid settings and on kinds the layer can't reproduce here.
|
||||
pub fn with_settings(base: &Document, kind: &str, params: &Value) -> Result<Document, String> {
|
||||
set_adjustment(base, PREVIEW_LAYER, kind, params)
|
||||
}
|
||||
|
||||
fn set_adjustment(base: &Document, layer: LayerId, kind: &str, params: &Value) -> Result<Document, String> {
|
||||
let adj = photocraft_engine::adjust_params::from_params(kind, params, None, base.mode).map_err(|e| e.to_string())?;
|
||||
if base.mode == ColorMode::Grayscale && !keeps_gray(&adj) {
|
||||
return Err("colour result in a grayscale document".into());
|
||||
}
|
||||
let mut out = base.clone();
|
||||
let l = out.layer_mut(layer).ok_or("no preview layer")?;
|
||||
l.content = LayerContent::Adjustment(adj);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `doc` with the dialog's adjustment as a temporary layer clipped to `target` (the selection as
|
||||
/// its mask): [`base_document`] then [`with_settings`].
|
||||
pub fn preview_document(doc: &Document, target: LayerId, kind: &str, params: &Value) -> Result<Document, String> {
|
||||
with_settings(&base_document(doc, target)?, kind, params)
|
||||
}
|
||||
|
||||
/// The canvas area the preview can change: the target's content within the selection, grown by
|
||||
/// the reach of layer effects (the target's and its groups').
|
||||
pub fn region(doc: &Document, target: LayerId) -> Rect {
|
||||
let Some(s) = doc.layer(target).and_then(Layer::surface) else { return Rect::EMPTY };
|
||||
let mut r = s.content_bounds().intersect(&doc.bounds());
|
||||
if let Some(sel) = &doc.selection {
|
||||
r = r.intersect(&sel.content_bounds());
|
||||
}
|
||||
if r.is_empty() {
|
||||
return r;
|
||||
}
|
||||
r.inflate(crate::canvas::effect_reach(&doc.layers)).intersect(&doc.bounds())
|
||||
}
|
||||
|
||||
/// Per-dialog preview state on the app.
|
||||
pub struct AdjustPreview {
|
||||
doc: DocId,
|
||||
revision: u64,
|
||||
/// Session part of the keys (document, revision, target, channel target).
|
||||
session: u64,
|
||||
/// The session's [`base_document`], or None when it previews on the CPU proxy.
|
||||
base: Option<Arc<Document>>,
|
||||
region: Rect,
|
||||
/// Hash of the kind, settings and Preview checkbox (0: no dialog).
|
||||
hash: u64,
|
||||
/// Whether the layer path handles these settings (false: CPU proxy preview).
|
||||
layer_path: bool,
|
||||
/// The preview document and its key, or None (proxy path, or the Preview checkbox is off).
|
||||
shown: Option<(Arc<Document>, u64)>,
|
||||
/// When the settings last changed (ms), for throttling secondary views.
|
||||
changed_ms: f64,
|
||||
/// The dialog's kind and parameters.
|
||||
settings: (String, Value),
|
||||
/// Reduced-resolution copy for zoomed-out views (see [`gpu_proxy`]).
|
||||
proxy: Option<GpuProxy>,
|
||||
/// The main canvas texture shows preview `key` only within this rect (the view when it was
|
||||
/// composited, see [`switch_damage`]); elsewhere in the region it is older.
|
||||
covered: Option<(u64, Rect)>,
|
||||
}
|
||||
|
||||
/// A [`base_document`] scaled down by `k` for zoomed-out GPU previews, with its own document and
|
||||
/// layer ids (so its textures live beside the full-size document's), plus the preview built from
|
||||
/// it for the current settings.
|
||||
struct GpuProxy {
|
||||
k: u32,
|
||||
base: Arc<Document>,
|
||||
/// (settings hash, preview).
|
||||
shown: Option<(u64, Arc<Document>)>,
|
||||
/// Settings hash last composited into the proxy's texture.
|
||||
drawn: Option<u64>,
|
||||
}
|
||||
|
||||
/// The open adjustment dialog: (kind, params, Preview on).
|
||||
fn dialog(app: &PhotocraftApp) -> Option<(String, Value, bool)> {
|
||||
let d = app.ui.dialogs.iter().find(|d| crate::adjust_dialog::owns(&d.fields))?;
|
||||
let kind = d.fields.get("__adjust")?.as_str()?.to_string();
|
||||
let preview = d.fields.get("__preview").and_then(Value::as_bool).unwrap_or(true);
|
||||
Some((kind, crate::filter_dialog::params_of(&d.fields), preview))
|
||||
}
|
||||
|
||||
/// FNV-1a over `parts`, never 0.
|
||||
fn hash(parts: &[&[u8]]) -> u64 {
|
||||
parts.iter().flat_map(|p| p.iter()).fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)) | 1
|
||||
}
|
||||
|
||||
/// Whether the layer path can draw `doc` cheaply on this app's canvas.
|
||||
fn cheap(app: &PhotocraftApp, doc: &Document) -> bool {
|
||||
match &app.gpu {
|
||||
Some(g) => g.supports(doc),
|
||||
None => crate::proxy::factor(doc) <= 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the cached preview up to date for document `idx` (the active one) and return it.
|
||||
fn update(app: &mut PhotocraftApp, idx: usize) -> Option<&AdjustPreview> {
|
||||
if app.session.active_index() != Some(idx) {
|
||||
return None;
|
||||
}
|
||||
let Some((kind, params, preview)) = dialog(app) else {
|
||||
// Keep a closed dialog's session for the canvas's switch back (see `switch_region`) but
|
||||
// never show it.
|
||||
if let Some(p) = app.adjust_preview.as_mut() {
|
||||
(p.hash, p.layer_path, p.shown) = (0, false, None);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
let st = app.session.documents().get(idx)?;
|
||||
let (doc_id, revision, target) = (st.doc.id, st.revision, st.active_layer?);
|
||||
// Commands that target a channel (Channels panel / Quick Mask) keep the proxy preview.
|
||||
let channel_target = st.channel_view.target != photocraft_engine::channel_cmds::ChannelTarget::Composite || st.doc.quick_mask.is_some();
|
||||
let session = hash(&[&doc_id.0.to_le_bytes(), &revision.to_le_bytes(), &target.0.to_le_bytes(), &[u8::from(channel_target)]]);
|
||||
let h = hash(&[kind.as_bytes(), params.to_string().as_bytes(), &[u8::from(preview)]]);
|
||||
if let Some(p) = &app.adjust_preview
|
||||
&& (p.doc, p.session, p.hash) == (doc_id, session, h)
|
||||
{
|
||||
return app.adjust_preview.as_ref();
|
||||
}
|
||||
if app.adjust_preview.as_ref().is_none_or(|p| (p.doc, p.session) != (doc_id, session)) {
|
||||
let doc = st.doc.clone();
|
||||
let base = if channel_target { None } else { base_document(&doc, target).ok().filter(|b| cheap(app, b)).map(Arc::new) };
|
||||
let region = if base.is_some() { region(&doc, target) } else { Rect::EMPTY };
|
||||
app.adjust_preview = Some(AdjustPreview {
|
||||
doc: doc_id,
|
||||
revision,
|
||||
session,
|
||||
base,
|
||||
region,
|
||||
hash: 0,
|
||||
layer_path: false,
|
||||
shown: None,
|
||||
changed_ms: crate::gpu_canvas::now_ms(),
|
||||
settings: (String::new(), Value::Null),
|
||||
proxy: None,
|
||||
covered: None,
|
||||
});
|
||||
}
|
||||
let p = app.adjust_preview.as_mut()?;
|
||||
let built = p.base.as_ref().and_then(|b| with_settings(b, &kind, ¶ms).ok());
|
||||
let key = TAG | ((session & 0x7_ffff) << SESSION_SHIFT) | (h & ((1 << SESSION_SHIFT) - 1));
|
||||
p.layer_path = built.is_some();
|
||||
p.shown = built.filter(|_| preview).map(|d| (Arc::new(d), key));
|
||||
p.hash = h;
|
||||
p.changed_ms = crate::gpu_canvas::now_ms();
|
||||
p.settings = (kind, params);
|
||||
app.adjust_preview.as_ref()
|
||||
}
|
||||
|
||||
/// The document the canvas shows for document `idx` while an adjustment dialog previews through
|
||||
/// the layer path: (document, preview key).
|
||||
pub fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
|
||||
update(app, idx)?.shown.clone()
|
||||
}
|
||||
|
||||
/// Whether the open adjustment dialog previews through the layer path (so the CPU proxy preview
|
||||
/// must stay off).
|
||||
pub fn on_layer(app: &mut PhotocraftApp, idx: usize) -> bool {
|
||||
update(app, idx).is_some_and(|p| p.layer_path)
|
||||
}
|
||||
|
||||
/// The canvas area to recomposite when document `idx` (at `revision`) switches from the preview
|
||||
/// key `from` to `to` (raw keys, display transform removed): both must be this dialog's previews
|
||||
/// or the committed document (0). None means "everything".
|
||||
pub fn switch_region(app: &PhotocraftApp, doc: DocId, revision: u64, from: u64, to: u64) -> Option<Rect> {
|
||||
let p = app.adjust_preview.as_ref()?;
|
||||
if p.doc != doc || p.revision != revision {
|
||||
return None;
|
||||
}
|
||||
let prefix = TAG | ((p.session & 0x7_ffff) << SESSION_SHIFT);
|
||||
let ours = |k: u64| k == 0 || k >> SESSION_SHIFT == prefix >> SESSION_SHIFT;
|
||||
(ours(from) && ours(to) && (from != 0 || to != 0)).then_some(p.region)
|
||||
}
|
||||
|
||||
/// The key of the preview currently shown, if any.
|
||||
pub fn shown_key(app: &PhotocraftApp) -> Option<u64> {
|
||||
app.adjust_preview.as_ref()?.shown.as_ref().map(|s| s.1)
|
||||
}
|
||||
|
||||
/// The part of a switch's region (from [`switch_region`]) to composite now: to a preview, only
|
||||
/// what the view shows (`visible`, document pixels), remembering it so [`uncovered`] catches up
|
||||
/// as the view moves; back to the committed document (key 0), the whole region.
|
||||
pub fn switch_damage(app: &mut PhotocraftApp, to: u64, region: Rect, visible: Rect) -> Rect {
|
||||
let Some(p) = app.adjust_preview.as_mut() else { return region };
|
||||
if to == 0 {
|
||||
p.covered = None;
|
||||
return region;
|
||||
}
|
||||
let shown = region.intersect(&visible);
|
||||
p.covered = Some((to, shown));
|
||||
shown
|
||||
}
|
||||
|
||||
/// Where the main canvas texture (showing preview `key`) is out of date within `visible`: the
|
||||
/// region's visible part when the view moved past what was composited. Marks it covered.
|
||||
pub fn uncovered(app: &mut PhotocraftApp, doc: DocId, key: u64, visible: Rect) -> Option<Rect> {
|
||||
let p = app.adjust_preview.as_mut().filter(|p| p.doc == doc)?;
|
||||
let (k, cov) = p.covered?;
|
||||
if k != key {
|
||||
return None;
|
||||
}
|
||||
let want = p.region.intersect(&visible);
|
||||
if want.is_empty() || cov.contains_rect(&want) {
|
||||
return None;
|
||||
}
|
||||
p.covered = Some((k, want));
|
||||
Some(want)
|
||||
}
|
||||
|
||||
/// True while the preview's settings changed within the last `ms` milliseconds (secondary views
|
||||
/// such as the Navigator wait for the settings to settle instead of recompositing every change).
|
||||
pub fn settling(app: &PhotocraftApp, ms: f64) -> bool {
|
||||
app.adjust_preview.as_ref().is_some_and(|p| p.shown.is_some() && crate::gpu_canvas::now_ms() - p.changed_ms < ms)
|
||||
}
|
||||
|
||||
/// Documents above this size preview zoomed-out views on a reduced copy ([`gpu_proxy`]).
|
||||
const PROXY_MIN_PIXELS: u64 = 4_000_000;
|
||||
/// Proxy document ids: the document's id with this bit flipped.
|
||||
const PROXY_DOC_BIT: u64 = 1 << 60;
|
||||
/// Proxy layer ids: the layer's id with this bit flipped (document layer ids are small counters;
|
||||
/// [`PREVIEW_LAYER`] has it set).
|
||||
const PROXY_LAYER_BIT: u64 = 1 << 62;
|
||||
|
||||
fn flip_ids(layers: &mut [Layer]) {
|
||||
for l in layers {
|
||||
l.id = LayerId(l.id.0 ^ PROXY_LAYER_BIT);
|
||||
if let Some(ch) = l.children_mut() {
|
||||
flip_ids(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy factor for a view at `zoom` (physical screen pixels per document pixel): the largest
|
||||
/// power of two that keeps the proxy at least at the screen's resolution; 1 when the view is too
|
||||
/// close for a proxy.
|
||||
pub fn proxy_factor(zoom: f32) -> u32 {
|
||||
if zoom.is_nan() || zoom <= 0.0 {
|
||||
return 1;
|
||||
}
|
||||
let mut k = 1u32;
|
||||
while k < 64 && (k * 2) as f32 * zoom <= 1.0 {
|
||||
k *= 2;
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// A [`base_document`] scaled down by `k` with its own document and layer ids (so the GPU keeps
|
||||
/// its textures beside the full-size document's).
|
||||
pub fn proxy_base(base: &Document, k: u32) -> Document {
|
||||
let mut d = photocraft_compose::proxy::proxy_document(base, k);
|
||||
d.id = photocraft_doc::DocId(base.id.0 ^ PROXY_DOC_BIT);
|
||||
flip_ids(&mut d.layers);
|
||||
d
|
||||
}
|
||||
|
||||
/// [`with_settings`] for a [`proxy_base`].
|
||||
pub fn proxy_with_settings(proxy: &Document, kind: &str, params: &Value) -> Result<Document, String> {
|
||||
set_adjustment(proxy, LayerId(PREVIEW_LAYER.0 ^ PROXY_LAYER_BIT), kind, params)
|
||||
}
|
||||
|
||||
/// A frame of the zoomed-out preview: composite `doc` (the proxy preview, its own document id)
|
||||
/// into its GPU texture over `damage` (None: everything), then draw it scaled by `k`.
|
||||
pub struct ProxyFrame {
|
||||
pub k: u32,
|
||||
pub doc: Arc<Document>,
|
||||
pub damage: Option<Rect>,
|
||||
/// False when the texture already shows this preview.
|
||||
pub stale: bool,
|
||||
hash: u64,
|
||||
}
|
||||
|
||||
/// The zoomed-out preview for document `idx` at `zoom`, when the dialog previews through the layer
|
||||
/// and the view is far enough out that a reduced copy shows the same detail as the full-size
|
||||
/// composite: each change then composites ~1/k² of the pixels. Built once per session and factor.
|
||||
pub fn gpu_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option<ProxyFrame> {
|
||||
let gpu = app.gpu.clone()?;
|
||||
update(app, idx)?;
|
||||
let p = app.adjust_preview.as_mut()?;
|
||||
p.shown.as_ref()?;
|
||||
let base = p.base.clone()?;
|
||||
let k = proxy_factor(zoom);
|
||||
if k < 2 || base.size.area() <= PROXY_MIN_PIXELS || !photocraft_compose::proxy::proxy_faithful(&base) {
|
||||
return None;
|
||||
}
|
||||
if p.proxy.as_ref().is_none_or(|g| g.k != k) {
|
||||
let d = proxy_base(&base, k);
|
||||
if !gpu.supports(&d) {
|
||||
return None;
|
||||
}
|
||||
p.proxy = Some(GpuProxy { k, base: Arc::new(d), shown: None, drawn: None });
|
||||
}
|
||||
let (kind, params) = &p.settings;
|
||||
let h = p.hash;
|
||||
let g = p.proxy.as_mut()?;
|
||||
if g.shown.as_ref().is_none_or(|(sh, _)| *sh != h) {
|
||||
let d = proxy_with_settings(&g.base, kind, params).ok()?;
|
||||
g.shown = Some((h, Arc::new(d)));
|
||||
}
|
||||
let doc = g.shown.as_ref()?.1.clone();
|
||||
let r = p.region;
|
||||
let kk = k as i32;
|
||||
let scaled = Rect::new(r.x0.div_euclid(kk), r.y0.div_euclid(kk), r.x1.div_euclid(kk) + 1, r.y1.div_euclid(kk) + 1).intersect(&doc.bounds());
|
||||
let damage = g.drawn.map(|_| scaled);
|
||||
Some(ProxyFrame { k, doc, damage, stale: g.drawn != Some(h), hash: h })
|
||||
}
|
||||
|
||||
/// Record that `frame` was composited into the proxy's texture.
|
||||
pub fn proxy_drawn(app: &mut PhotocraftApp, frame: &ProxyFrame) {
|
||||
if let Some(g) = app.adjust_preview.as_mut().and_then(|p| p.proxy.as_mut())
|
||||
&& g.k == frame.k
|
||||
{
|
||||
g.drawn = Some(frame.hash);
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU texture keys the preview uses besides the documents' own (keep them alive).
|
||||
pub fn gpu_keys(app: &PhotocraftApp) -> Option<u64> {
|
||||
let p = app.adjust_preview.as_ref().filter(|p| p.hash != 0)?;
|
||||
p.proxy.as_ref().map(|g| g.base.id.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "adjust_preview_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,335 @@
|
||||
use super::*;
|
||||
use photocraft_color::{BlendMode, Color, PixelFormat, SampleType};
|
||||
use photocraft_doc::Size;
|
||||
use serde_json::json;
|
||||
|
||||
const KINDS: [&str; 13] = crate::adjust_editors::KINDS;
|
||||
|
||||
/// Settings that move every kind well away from neutral.
|
||||
pub(crate) fn sample(kind: &str) -> Value {
|
||||
match kind {
|
||||
"brightnessContrast" => json!({"brightness": 40, "contrast": 30}),
|
||||
"levels" => json!({"inBlack": 30, "inWhite": 220, "gamma": 1.3, "outBlack": 10}),
|
||||
"curves" => json!({"points": [[0, 20], [90, 150], [255, 230]]}),
|
||||
"exposure" => json!({"exposure": 0.8, "offset": 0.02, "gamma": 0.9}),
|
||||
"vibrance" => json!({"vibrance": 50, "saturation": 20}),
|
||||
"hueSaturation" => json!({"hue": 25, "saturation": 30, "reds": {"hue": 40}}),
|
||||
"colorBalance" => json!({"midtones": [40, 0, -20], "shadows": [0, 20, 0]}),
|
||||
"blackWhite" => json!({"reds": 150, "tint": true}),
|
||||
"photoFilter" => json!({"filter": "cooling80", "density": 60}),
|
||||
"channelMixer" => json!({"red": [50, 50, 0, 0], "blue": [0, 20, 90, 5]}),
|
||||
"posterize" => json!({"levels": 3}),
|
||||
"threshold" => json!({"level": 90}),
|
||||
"gradientMap" => json!({"stops": [[0, "#200040"], [1, "#ffd080"]]}),
|
||||
_ => json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn rnd(seed: u32, i: u32) -> f32 {
|
||||
let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b);
|
||||
h ^= h >> 13;
|
||||
h = h.wrapping_mul(0xc2b2_ae35);
|
||||
h ^= h >> 16;
|
||||
(h & 0xffff) as f32 / 65535.0
|
||||
}
|
||||
|
||||
fn fill_noise(s: &mut photocraft_raster::Surface, r: Rect, seed: u32, min_alpha: f32) {
|
||||
let fmt = s.format();
|
||||
let ch = fmt.channels();
|
||||
let n = r.width() * r.height();
|
||||
let mut data = Vec::with_capacity(n as usize * ch);
|
||||
for i in 0..n {
|
||||
for c in 0..ch {
|
||||
let v = rnd(seed + c as u32 * 7919, i);
|
||||
let alpha = fmt.alpha && c == ch - 1;
|
||||
data.push(if alpha { min_alpha + (1.0 - min_alpha) * v } else { v });
|
||||
}
|
||||
}
|
||||
s.write_region(r, &data);
|
||||
}
|
||||
|
||||
/// Background, the target (semi-transparent noise, 80 % Multiply, layer mask), a layer clipped to
|
||||
/// the target and a Screen layer on top: what the preview layer is inserted into.
|
||||
pub(crate) fn document(mode: ColorMode, depth: SampleType, selection: bool) -> (Document, LayerId) {
|
||||
let (w, h) = (40u32, 32u32);
|
||||
let mut doc = Document::with_background("t", Size::new(w, h), mode, depth, Color::rgba(0.7, 0.6, 0.5, 1.0));
|
||||
let fmt = doc.pixel_format();
|
||||
if let Some(s) = doc.layers[0].surface_mut() {
|
||||
fill_noise(s, Rect::from_xywh(0, 0, w, h / 2), 3, 1.0);
|
||||
}
|
||||
let mut target = Layer::raster("target", fmt);
|
||||
if let Some(s) = target.surface_mut() {
|
||||
fill_noise(s, Rect::from_xywh(4, 2, 30, 26), 11, 0.2);
|
||||
}
|
||||
target.opacity = 0.8;
|
||||
target.blend = BlendMode::Multiply;
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.surface.fill_rect(Rect::from_xywh(0, 0, 10, 10), &[0.3]);
|
||||
target.mask = Some(m);
|
||||
let id = target.id;
|
||||
let mut clip = Layer::raster("clipped", fmt);
|
||||
if let Some(s) = clip.surface_mut() {
|
||||
fill_noise(s, Rect::from_xywh(20, 10, 12, 12), 17, 0.5);
|
||||
}
|
||||
clip.clipped = true;
|
||||
clip.opacity = 0.6;
|
||||
let mut top = Layer::raster("top", fmt);
|
||||
if let Some(s) = top.surface_mut() {
|
||||
fill_noise(s, Rect::from_xywh(0, 20, 40, 12), 23, 0.0);
|
||||
}
|
||||
top.blend = BlendMode::Screen;
|
||||
doc.layers.extend([target, clip, top]);
|
||||
if selection {
|
||||
let mut sel = photocraft_raster::Surface::new(PixelFormat::GRAY8);
|
||||
sel.fill_rect(Rect::from_xywh(8, 4, 24, 20), &[1.0]);
|
||||
sel.fill_rect(Rect::from_xywh(8, 4, 6, 20), &[0.5]);
|
||||
doc.selection = Some(sel);
|
||||
}
|
||||
(doc, id)
|
||||
}
|
||||
|
||||
/// The document after running the real destructive command on `target`.
|
||||
pub(crate) fn destructive(doc: &Document, target: LayerId, kind: &str, params: &Value) -> Option<Document> {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.add_document(doc.clone(), None);
|
||||
s.select_layer(target).ok()?;
|
||||
s.execute(&format!("image.adjustments.{kind}"), params.clone()).ok()?;
|
||||
Some((*s.active()?.doc).clone())
|
||||
}
|
||||
|
||||
/// Largest premultiplied difference between two composites.
|
||||
pub(crate) fn max_diff(a: &Document, b: &Document) -> f32 {
|
||||
let (ra, rb) = (photocraft_compose::render(a, a.bounds()), photocraft_compose::render(b, b.bounds()));
|
||||
let pm = |p: &[f32; 4]| [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]];
|
||||
ra.px.iter().zip(&rb.px).map(|(x, y)| (0..4).map(|c| (pm(x)[c] - pm(y)[c]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Kinds that make colour from gray: grayscale documents preview them on the CPU proxy.
|
||||
const GRAY_COLOUR: [&str; 6] = ["vibrance", "colorBalance", "blackWhite", "photoFilter", "channelMixer", "gradientMap"];
|
||||
|
||||
#[test]
|
||||
fn preview_composite_matches_the_destructive_command() {
|
||||
let tol = 1.0 / 255.0 + 1e-4;
|
||||
let mut worst = (0.0f32, String::new());
|
||||
let mut fails = Vec::new();
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for selection in [false, true] {
|
||||
let (doc, target) = document(mode, depth, selection);
|
||||
for kind in KINDS {
|
||||
for params in [sample(kind), json!({})] {
|
||||
let what = format!("{kind} {mode:?} {depth:?} selection {selection} {params}");
|
||||
let want = destructive(&doc, target, kind, ¶ms);
|
||||
let got = preview_document(&doc, target, kind, ¶ms);
|
||||
let gray_colour = mode == ColorMode::Grayscale && GRAY_COLOUR.contains(&kind);
|
||||
let (Some(want), Ok(got)) = (want, got) else {
|
||||
assert!(gray_colour, "{what}: command and preview disagree on validity");
|
||||
continue;
|
||||
};
|
||||
assert!(!gray_colour, "{what}: should use the proxy preview");
|
||||
let d = max_diff(&got, &want);
|
||||
if d > worst.0 {
|
||||
worst = (d, what.clone());
|
||||
}
|
||||
if d > tol {
|
||||
fails.push(format!("{what}: preview differs from the command by {:.2}/255", d * 255.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("worst preview difference {:.3}/255 ({})", worst.0 * 255.0, worst.1);
|
||||
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_leaves_the_document_alone_and_marks_its_layer() {
|
||||
let (doc, target) = document(ColorMode::Rgb, SampleType::U8, true);
|
||||
let p = preview_document(&doc, target, "curves", &sample("curves")).unwrap();
|
||||
assert_eq!(p.layer_count(), doc.layer_count() + 1);
|
||||
let l = p.layer(PREVIEW_LAYER).unwrap();
|
||||
assert!(l.clipped && l.mask.is_some());
|
||||
// Directly above the target, below the layer already clipped to it.
|
||||
let path = p.path_of(PREVIEW_LAYER).unwrap();
|
||||
assert_eq!(p.layer_at(&[path[0] - 1]).unwrap().id, target);
|
||||
assert!(doc.layer(PREVIEW_LAYER).is_none());
|
||||
// Region: the target's content within the selection.
|
||||
assert_eq!(region(&doc, target), Rect::from_xywh(8, 4, 24, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ineligible_targets_use_the_proxy_preview() {
|
||||
let (mut doc, target) = document(ColorMode::Rgb, SampleType::U8, false);
|
||||
let clipped = doc.layers[2].id;
|
||||
assert_eq!(unsupported(&doc, clipped), Some("the target is clipped"));
|
||||
assert_eq!(unsupported(&doc, LayerId(u64::MAX - 1)), Some("no target layer"));
|
||||
doc.layers.push(Layer::new("adj", LayerContent::Adjustment(photocraft_doc::Adjustment::Invert)));
|
||||
let adj = doc.layers[4].id;
|
||||
assert_eq!(unsupported(&doc, adj), Some("not a pixel layer"));
|
||||
assert!(unsupported(&doc, target).is_none());
|
||||
// Bad settings are an error, not a panic.
|
||||
assert!(preview_document(&doc, target, "levels", &json!({"gamma": "x"})).is_err());
|
||||
assert!(preview_document(&doc, target, "nope", &json!({})).is_err());
|
||||
assert!(preview_document(&doc, target, "curves", &json!([1, 2])).is_err());
|
||||
// CMYK and Lab adjust through RGB per pixel: proxy preview.
|
||||
for mode in [ColorMode::Cmyk, ColorMode::Lab] {
|
||||
let (d, t) = document(mode, SampleType::U8, false);
|
||||
assert!(unsupported(&d, t).is_some(), "{mode:?}");
|
||||
}
|
||||
let (gray, t) = document(ColorMode::Grayscale, SampleType::U16, false);
|
||||
assert!(preview_document(&gray, t, "hueSaturation", &json!({"colorize": true, "hue": 30, "saturation": 40})).is_err());
|
||||
assert!(preview_document(&gray, t, "hueSaturation", &json!({"lightness": 20})).is_ok());
|
||||
}
|
||||
|
||||
/// The canvas recomposites only `region` between previews: nothing outside it may change.
|
||||
#[test]
|
||||
fn preview_changes_nothing_outside_its_region() {
|
||||
for selection in [false, true] {
|
||||
let (doc, target) = document(ColorMode::Rgb, SampleType::U8, selection);
|
||||
let r = region(&doc, target);
|
||||
let before = photocraft_compose::render(&doc, doc.bounds());
|
||||
for kind in KINDS {
|
||||
let p = preview_document(&doc, target, kind, &sample(kind)).unwrap();
|
||||
let after = photocraft_compose::render(&p, doc.bounds());
|
||||
for (i, (a, b)) in before.px.iter().zip(&after.px).enumerate() {
|
||||
let (x, y) = ((i % 40) as i32, (i / 40) as i32);
|
||||
if !r.contains(x, y) {
|
||||
assert_eq!(a, b, "{kind} selection {selection}: ({x}, {y}) outside {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn app_with(doc: Document, target: LayerId) -> PhotocraftApp {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.add_document(doc, None);
|
||||
s.select_layer(target).unwrap();
|
||||
PhotocraftApp::new(s, crate::Services::default())
|
||||
}
|
||||
|
||||
/// The dialog previews through the layer (no CPU proxy run), each change only swaps the
|
||||
/// preview document; Cancel leaves the document untouched, OK is one history step.
|
||||
#[test]
|
||||
fn dialog_previews_through_the_layer() {
|
||||
use egui_kittest::Harness;
|
||||
for confirm in [false, true] {
|
||||
let (doc, target) = document(ColorMode::Rgb, SampleType::U16, true);
|
||||
let mut harness =
|
||||
Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with(doc, target));
|
||||
PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
let id = crate::adjust_dialog::open(harness.state_mut(), "image.adjustments.levels").unwrap();
|
||||
harness.run_steps(2);
|
||||
let committed = harness.state().session.active().unwrap().doc.clone();
|
||||
let steps = harness.state().session.active().unwrap().history.past_len();
|
||||
let app = harness.state_mut();
|
||||
assert!(on_layer(app, 0));
|
||||
let (first, k1) = display_doc(app, 0).unwrap();
|
||||
assert!(first.layer(PREVIEW_LAYER).is_some());
|
||||
// Unchanged settings: the cached preview, same key.
|
||||
let (again, k1b) = display_doc(app, 0).unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &again) && k1 == k1b);
|
||||
// A change: new key, and the canvas only recomposites the target's area.
|
||||
let d = app.ui.dialog_mut(id).unwrap();
|
||||
d.fields.insert("gamma".into(), json!(1.6));
|
||||
let (second, k2) = display_doc(app, 0).unwrap();
|
||||
assert_ne!(k1, k2);
|
||||
let rev = app.session.active().unwrap().revision;
|
||||
let doc_id = committed.id;
|
||||
assert_eq!(switch_region(app, doc_id, rev, k1, k2), Some(Rect::from_xywh(8, 4, 24, 20)));
|
||||
assert_eq!(switch_region(app, doc_id, rev, 0, k2), Some(Rect::from_xywh(8, 4, 24, 20)));
|
||||
assert_eq!(switch_region(app, doc_id, rev, 1 << 41, k2), None, "another preview kind");
|
||||
assert_eq!(switch_region(app, doc_id, rev + 1, k1, k2), None, "another revision");
|
||||
// Only the visible part composites now; the rest when the view gets there.
|
||||
let region = Rect::from_xywh(8, 4, 24, 20);
|
||||
let (corner, all) = (Rect::from_xywh(0, 0, 16, 16), Rect::from_xywh(0, 0, 40, 32));
|
||||
assert_eq!(switch_damage(app, k2, region, corner), Rect::new(8, 4, 16, 16));
|
||||
assert_eq!(uncovered(app, doc_id, k2, corner), None);
|
||||
assert_eq!(uncovered(app, doc_id, k1, all), None, "another preview");
|
||||
assert_eq!(uncovered(app, doc_id, k2, all), Some(region));
|
||||
assert_eq!(uncovered(app, doc_id, k2, all), None);
|
||||
assert_eq!(switch_damage(app, 0, region, corner), region, "back to the document: everything");
|
||||
assert_eq!(uncovered(app, doc_id, k2, all), None);
|
||||
// The preview equals the command with the dialog's settings.
|
||||
let params = crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields);
|
||||
let want = destructive(&committed, target, "levels", ¶ms).unwrap();
|
||||
assert!(max_diff(&second, &want) <= 1.0 / 255.0);
|
||||
// Preview off: the committed document.
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("__preview".into(), json!(false));
|
||||
assert!(display_doc(app, 0).is_none());
|
||||
assert!(on_layer(app, 0), "still the layer path, just hidden");
|
||||
harness.run_steps(2);
|
||||
assert!(Arc::ptr_eq(&harness.state().session.active().unwrap().doc, &committed), "previewing leaves the document alone");
|
||||
if confirm {
|
||||
crate::dialogs::confirm(harness.state_mut(), id).unwrap();
|
||||
let st = harness.state().session.active().unwrap();
|
||||
assert_eq!(st.history.past_len(), steps + 1, "one history step");
|
||||
assert!(st.doc.layer(PREVIEW_LAYER).is_none());
|
||||
assert!(max_diff(&st.doc, &want) <= 1e-6, "OK runs the real command");
|
||||
} else {
|
||||
harness.state_mut().ui.close_dialog(id);
|
||||
harness.run_steps(2);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
assert!(Arc::ptr_eq(&st.doc, &committed), "Cancel leaves the document untouched");
|
||||
assert_eq!(st.history.past_len(), steps);
|
||||
}
|
||||
let app = harness.state_mut();
|
||||
assert!(display_doc(app, 0).is_none() && !on_layer(app, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Targets the layer path can't reproduce keep the CPU proxy preview.
|
||||
#[test]
|
||||
fn ineligible_dialogs_keep_the_proxy_preview() {
|
||||
// A clipped target.
|
||||
let (doc, _) = document(ColorMode::Rgb, SampleType::U8, false);
|
||||
let clipped = doc.layers[2].id;
|
||||
let mut app = app_with(doc, clipped);
|
||||
crate::adjust_dialog::open(&mut app, "image.adjustments.curves").unwrap();
|
||||
assert!(!on_layer(&mut app, 0) && display_doc(&mut app, 0).is_none());
|
||||
// An alpha-channel target.
|
||||
let (doc, target) = document(ColorMode::Rgb, SampleType::U8, false);
|
||||
let mut app = app_with(doc, target);
|
||||
app.session.execute("select.all", json!({})).unwrap();
|
||||
app.session.execute("select.saveSelection", json!({})).unwrap();
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.channel_view.target = photocraft_engine::channel_cmds::ChannelTarget::Alpha(0);
|
||||
}
|
||||
crate::adjust_dialog::open(&mut app, "image.adjustments.levels").unwrap();
|
||||
assert!(!on_layer(&mut app, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_factor_follows_the_zoom() {
|
||||
assert_eq!(proxy_factor(1.0), 1);
|
||||
assert_eq!(proxy_factor(0.6), 1);
|
||||
assert_eq!(proxy_factor(0.5), 2);
|
||||
assert_eq!(proxy_factor(0.2), 4);
|
||||
assert_eq!(proxy_factor(0.1), 8);
|
||||
assert_eq!(proxy_factor(0.0001), 64);
|
||||
assert_eq!(proxy_factor(0.0), 1);
|
||||
assert_eq!(proxy_factor(f32::NAN), 1);
|
||||
}
|
||||
|
||||
/// The zoomed-out proxy keeps the preview layer (with the dialog's settings) under its own ids.
|
||||
#[test]
|
||||
fn proxy_preview_has_its_own_ids() {
|
||||
let (doc, target) = document(ColorMode::Rgb, SampleType::U8, true);
|
||||
let base = base_document(&doc, target).unwrap();
|
||||
let proxy = proxy_base(&base, 2);
|
||||
assert_eq!((proxy.size.width, proxy.size.height), (20, 16));
|
||||
assert_ne!(proxy.id, doc.id);
|
||||
assert!(proxy.walk().iter().all(|(_, _, l)| doc.layer(l.id).is_none() && l.id != PREVIEW_LAYER));
|
||||
let p = proxy_with_settings(&proxy, "curves", &sample("curves")).unwrap();
|
||||
let full = with_settings(&base, "curves", &sample("curves")).unwrap();
|
||||
let (a, b) = (photocraft_compose::render(&p, p.bounds()), photocraft_compose::render(&full, full.bounds()));
|
||||
// Nearest-neighbour proxy: pixel (x, y) shows the full composite at (2x, 2y).
|
||||
for y in 0..16 {
|
||||
for x in 0..20 {
|
||||
let (pa, pb) = (a.px[y * 20 + x], b.px[y * 2 * 40 + x * 2]);
|
||||
assert!((0..4).all(|c| (pa[c] - pb[c]).abs() < 2.0 / 255.0), "({x}, {y}) {pa:?} vs {pb:?}");
|
||||
}
|
||||
}
|
||||
assert!(proxy_with_settings(&proxy, "levels", &json!({"gamma": "x"})).is_err());
|
||||
}
|
||||
@@ -211,11 +211,15 @@ fn canvas_display(app: &PhotocraftApp, doc: &Document) -> (Option<std::sync::Arc
|
||||
|
||||
/// The document to render: the committed one, or a clone with the live adjustment preview applied.
|
||||
fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>, u64) {
|
||||
let st = &app.session.documents()[idx];
|
||||
// Puppet / Perspective Warp previews hide the layer they draw on a mesh.
|
||||
if let Some(shown) = crate::distort_ui::display_doc(app, idx) {
|
||||
return shown;
|
||||
}
|
||||
// Image › Adjustments dialog: a temporary adjustment layer clipped to the target.
|
||||
if let Some(shown) = crate::adjust_preview::display_doc(app, idx) {
|
||||
return shown;
|
||||
}
|
||||
let st = &app.session.documents()[idx];
|
||||
if let Some(l) = live_stroke(app, idx) {
|
||||
return (l.stroke.doc.clone(), l.display_key());
|
||||
}
|
||||
@@ -264,6 +268,8 @@ fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>
|
||||
|
||||
/// Longest side of the Navigator panel's image (about twice the panel's width, for HiDPI).
|
||||
pub const NAVIGATOR_SIDE: u32 = 512;
|
||||
/// How long adjustment-dialog settings must stay unchanged before the navigator shows them.
|
||||
const NAVIGATOR_SETTLE_MS: f64 = 200.0;
|
||||
|
||||
/// The Navigator panel's image of document `idx`. With the CPU canvas that is the canvas texture
|
||||
/// itself; with the GPU canvas a thumbnail cached per revision, so an edit to a huge document
|
||||
@@ -283,6 +289,15 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
|
||||
{
|
||||
return Some(t.id());
|
||||
}
|
||||
// While an adjustment dialog's settings are changing, the navigator keeps its image and
|
||||
// catches up once they settle (a thumbnail composite per change would cost more than the canvas).
|
||||
if let Some((r, _, t)) = &cached
|
||||
&& *r == revision
|
||||
&& crate::adjust_preview::settling(app, NAVIGATOR_SETTLE_MS)
|
||||
{
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(NAVIGATOR_SETTLE_MS as u64));
|
||||
return Some(t.id());
|
||||
}
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let image = display_image(display.as_deref(), &photocraft_compose::thumbnail_buffer(&doc, NAVIGATOR_SIDE));
|
||||
let tex = match cached {
|
||||
@@ -303,7 +318,15 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize)
|
||||
let st = app.session.documents().get(idx)?;
|
||||
(st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id)
|
||||
};
|
||||
let (doc, preview_key) = display_doc(app, idx);
|
||||
let (mut doc, mut preview_key) = display_doc(app, idx);
|
||||
// A flipped view draws a GPU machine's canvas through here: an adjustment dialog's preview of
|
||||
// a large document (taken by the GPU path) would cost a full-size CPU composite per change.
|
||||
if crate::adjust_preview::shown_key(app) == Some(preview_key)
|
||||
&& let Some(st) = app.session.documents().get(idx)
|
||||
&& crate::proxy::factor(&st.doc) > 1
|
||||
{
|
||||
(doc, preview_key) = (st.doc.clone(), 0);
|
||||
}
|
||||
let (display, display_key) = canvas_display(app, &doc);
|
||||
let seen = app.canvases.get(&id).map(|c| (c.tex_revision, c.tex_preview_key));
|
||||
let damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, preview_key), display_key, last_damage));
|
||||
@@ -359,6 +382,13 @@ fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u6
|
||||
if seen.1 == now.1 ^ display_key && seen.0 + 1 == now.0 {
|
||||
return last_damage;
|
||||
}
|
||||
// Between an adjustment dialog's previews (and the document): the target's area.
|
||||
if seen.0 == now.0
|
||||
&& let Some(st) = app.session.documents().get(idx)
|
||||
&& let Some(r) = crate::adjust_preview::switch_region(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
|
||||
{
|
||||
return Some(r);
|
||||
}
|
||||
let l = live_stroke(app, idx).filter(|l| seen.0 == now.0 && l.display_key() == now.1)?;
|
||||
let r = l.since(seen.1 ^ display_key)?;
|
||||
Some(if r.is_empty() { r } else { r.inflate(effect_reach(&l.stroke.doc.layers)) })
|
||||
@@ -366,7 +396,7 @@ fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u6
|
||||
|
||||
/// How far beyond an edit's damage rect the composite can change: layer effects (shadows, glows,
|
||||
/// strokes, …) on the edited layer and on the groups around it reach that far.
|
||||
fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 {
|
||||
pub(crate) fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 {
|
||||
layers
|
||||
.iter()
|
||||
.filter(|l| l.visible)
|
||||
@@ -385,7 +415,7 @@ fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 {
|
||||
/// GPU path: make sure document `idx` is current in the GPU canvas. Brush strokes re-composite
|
||||
/// and upload only their damage rect; everything else re-composites the whole document.
|
||||
/// Returns false if there is no GPU canvas.
|
||||
fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
|
||||
fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool {
|
||||
let Some(gpu) = app.gpu.clone() else { return false };
|
||||
let Some((revision, last_damage, id)) = app
|
||||
.session
|
||||
@@ -395,11 +425,17 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let (doc, preview_key) = display_doc(app, idx);
|
||||
let (doc, raw_key) = display_doc(app, idx);
|
||||
let (display, display_key) = canvas_display(app, &doc);
|
||||
let seen = app.canvases.get(&id).map(|c| (c.revision, c.preview_key));
|
||||
let damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, preview_key), display_key, last_damage));
|
||||
let preview_key = preview_key ^ display_key;
|
||||
let mut damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, raw_key), display_key, last_damage));
|
||||
// To an adjustment dialog's preview: only what the view shows now (the rest as it moves there).
|
||||
if damage.is_some()
|
||||
&& let Some(r) = seen.filter(|s| s.0 == revision).and_then(|s| crate::adjust_preview::switch_region(app, id, revision, s.1 ^ display_key, raw_key))
|
||||
{
|
||||
damage = Some(crate::adjust_preview::switch_damage(app, raw_key, r, visible));
|
||||
}
|
||||
let preview_key = raw_key ^ display_key;
|
||||
let size = [doc.size.width, doc.size.height];
|
||||
let cache = app.canvases.entry(id).or_insert(CanvasCache {
|
||||
revision: 0,
|
||||
@@ -412,6 +448,11 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
|
||||
});
|
||||
let present = cache.on_gpu && gpu.has(id.0, size);
|
||||
if present && cache.revision == revision && cache.preview_key == preview_key {
|
||||
// An adjustment preview composited where the view was: catch up where it moved to.
|
||||
if let Some(r) = crate::adjust_preview::uncovered(app, id, raw_key, visible) {
|
||||
let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref());
|
||||
app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let partial = present && damage.is_some();
|
||||
@@ -437,6 +478,9 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
|
||||
|
||||
/// Live preview for an open filter dialog: run the filter on the proxy and upload it.
|
||||
fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64)> {
|
||||
if crate::adjust_preview::on_layer(app, idx) {
|
||||
return None;
|
||||
}
|
||||
let d = app.ui.dialogs.iter().find(|d| d.fields.contains_key("__filter"))?;
|
||||
if d.fields.get("__preview").and_then(serde_json::Value::as_bool) != Some(true) {
|
||||
return None;
|
||||
@@ -466,6 +510,31 @@ fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u6
|
||||
app.filter_preview.as_ref().filter(|p| p.result.is_some()).map(|p| (p.k, key))
|
||||
}
|
||||
|
||||
/// The document pixels a (non-rotated) view shows, with a margin for filtering.
|
||||
fn visible_doc_rect(xf: &ViewXform) -> DRect {
|
||||
let (a, b) = (xf.to_doc(xf.rect.min), xf.to_doc(xf.rect.max));
|
||||
let c = |v: f64| v.clamp(-1e9, 1e9) as i32;
|
||||
DRect::new(c(a[0].min(b[0]).floor()) - 2, c(a[1].min(b[1]).floor()) - 2, c(a[0].max(b[0]).ceil()) + 2, c(a[1].max(b[1]).ceil()) + 2)
|
||||
}
|
||||
|
||||
/// Zoomed-out Image › Adjustments preview on a large document: the wgpu compositor renders the
|
||||
/// reduced preview document (`adjust_preview::gpu_proxy`) into its own texture, over the target's
|
||||
/// area after the first frame. Returns (factor, gpu key) to draw.
|
||||
fn ensure_adjust_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option<(u32, u64)> {
|
||||
let frame = crate::adjust_preview::gpu_proxy(app, idx, zoom)?;
|
||||
let key = frame.doc.id.0;
|
||||
let size = [frame.doc.size.width, frame.doc.size.height];
|
||||
let gpu = app.gpu.clone()?;
|
||||
if frame.stale || !gpu.has(key, size) {
|
||||
let doc = app.session.documents().get(idx)?.doc.clone();
|
||||
let (display, _) = canvas_display(app, &doc);
|
||||
let r = gpu.refresh(key, &frame.doc, frame.damage, display.as_deref());
|
||||
app.perf.record(if r.kind.starts_with("gpu") { "gpu-adjust-proxy" } else { "adjust-proxy" }, r.px, r.composite_ms, r.upload_ms);
|
||||
crate::adjust_preview::proxy_drawn(app, &frame);
|
||||
}
|
||||
Some((frame.k, key))
|
||||
}
|
||||
|
||||
/// If a live adjustment preview is active on a large document, composite it on the proxy and upload
|
||||
/// it under its own GPU key. Returns (factor, gpu key) when the proxy should be drawn.
|
||||
fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64)> {
|
||||
@@ -521,7 +590,8 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if let Some(gpu) = &app.gpu {
|
||||
// Keep each document's texture plus its preview textures (filter preview, adjustment proxy);
|
||||
// retaining only document ids freed the previews every frame (blank canvas while previewing).
|
||||
let live: Vec<u64> = app.session.documents().iter().flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62)]).collect();
|
||||
let mut live: Vec<u64> = app.session.documents().iter().flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62)]).collect();
|
||||
live.extend(crate::adjust_preview::gpu_keys(app));
|
||||
gpu.retain(&live);
|
||||
}
|
||||
if app.ui.chrome.shows_home(app.session.documents().len()) {
|
||||
@@ -839,7 +909,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// A flipped view draws through the CPU path (the GPU canvas shader has no mirroring).
|
||||
if app.gpu.is_some()
|
||||
&& !flip
|
||||
&& let Some((k, key)) = ensure_filter_preview(app, idx).or_else(|| ensure_proxy_preview(app, idx))
|
||||
&& let Some((k, key)) = ensure_adjust_proxy(app, idx, view.zoom * ctx.pixels_per_point())
|
||||
.or_else(|| ensure_filter_preview(app, idx))
|
||||
.or_else(|| ensure_proxy_preview(app, idx))
|
||||
{
|
||||
on_gpu = true;
|
||||
let params = crate::gpu_canvas::ViewParams {
|
||||
@@ -857,7 +929,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
hdr: hdr_preview(app, &doc),
|
||||
};
|
||||
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
|
||||
} else if !flip && ensure_gpu(app, idx) {
|
||||
} else if !flip && ensure_gpu(app, idx, visible_doc_rect(&xf)) {
|
||||
on_gpu = true;
|
||||
app.perf.gpu = true;
|
||||
// Shadow, checkerboard, document and pixel grid in one custom shader (gpu_canvas.rs).
|
||||
|
||||
@@ -161,6 +161,26 @@ impl GpuCanvas {
|
||||
r.callback_resources.get::<Resources>().and_then(|res| res.docs.get(&doc)).is_some_and(|d| d.size == size)
|
||||
}
|
||||
|
||||
/// Whether [`GpuCanvas::composite`] would draw `doc` with the wgpu compositor (rather than
|
||||
/// fall back to the CPU compositor).
|
||||
pub fn supports(&self, doc: &photocraft_doc::Document) -> bool {
|
||||
if std::env::var_os("PHOTOCRAFT_CPU_COMPOSE").is_some() || doc.size.width == 0 || doc.size.height == 0 {
|
||||
return false;
|
||||
}
|
||||
let r = self.rs.renderer.read();
|
||||
let Some(res) = r.callback_resources.get::<Resources>() else { return false };
|
||||
if res.compositor_failed.is_some() {
|
||||
return false;
|
||||
}
|
||||
match &res.compositor {
|
||||
Some(c) => c.supports(doc).is_ok(),
|
||||
None => {
|
||||
let max = self.rs.device.limits().max_texture_dimension_2d;
|
||||
doc.size.width <= max && doc.size.height <= max && photocraft_gpu::plan(doc).is_ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the whole document image with an `Rgba8Unorm` texture. `rgba` is premultiplied
|
||||
/// RGBA8, `size[0] * size[1] * 4` bytes (anything else is ignored).
|
||||
pub fn upload_full(&self, doc: u64, size: [u32; 2], rgba: &[u8]) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
pub mod actions;
|
||||
pub mod adjust_dialog;
|
||||
pub mod adjust_editors;
|
||||
pub mod adjust_preview;
|
||||
pub mod adjust_ui;
|
||||
pub mod analysis_ui;
|
||||
pub mod artboard_ui;
|
||||
@@ -220,6 +221,8 @@ pub struct PhotocraftApp {
|
||||
pub(crate) filter_preview: Option<filter_dialog::FilterPreview>,
|
||||
/// Select › Color Range dialog preview (proxy document + mask / image textures).
|
||||
pub(crate) color_range: Option<color_range_ui::Preview>,
|
||||
/// Image › Adjustments dialog preview through a temporary clipped adjustment layer.
|
||||
pub(crate) adjust_preview: Option<adjust_preview::AdjustPreview>,
|
||||
/// Synthetic input events queued by automation (`ui.click`, `ui.key`, …), injected next frame.
|
||||
pub(crate) synthetic: Vec<egui::Event>,
|
||||
/// True only while a frame is processing synthetic automation input. It
|
||||
@@ -301,6 +304,7 @@ impl PhotocraftApp {
|
||||
outline_cache: None,
|
||||
filter_preview: None,
|
||||
color_range: None,
|
||||
adjust_preview: None,
|
||||
synthetic: Vec::new(),
|
||||
automation_input: false,
|
||||
channel_thumbs: None,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! GPU parity of the Image › Adjustments dialog preview (`adjust_preview`): the document with
|
||||
//! the temporary clipped adjustment layer must composite on the wgpu compositor like on the CPU
|
||||
//! reference, and like the destructive command's result, for every kind with a dialog. Skips
|
||||
//! without an adapter that renders 32-bit float targets (like `crates/gpu/tests/parity.rs`).
|
||||
|
||||
use eframe::wgpu;
|
||||
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerId, LayerMask, Size};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_gpu::{Compositor, render_to_vec};
|
||||
use photocraft_ui_egui::adjust_preview::{PREVIEW_LAYER, preview_document};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn block_on<F: std::future::Future>(f: F) -> F::Output {
|
||||
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
|
||||
let mut f = std::pin::pin!(f);
|
||||
loop {
|
||||
if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) {
|
||||
return v;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
|
||||
fn gpu() -> Option<(wgpu::Device, wgpu::Queue, Compositor)> {
|
||||
let adapter = match block_on(wgpu::Instance::default().request_adapter(&wgpu::RequestAdapterOptions::default())) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("skipping adjustment preview GPU parity: no adapter ({e})");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float {
|
||||
eprintln!("skipping adjustment preview GPU parity: adapter can't render Rgba32Float");
|
||||
return None;
|
||||
}
|
||||
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
|
||||
let comp = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float).ok()?;
|
||||
Some((device, queue, comp))
|
||||
}
|
||||
|
||||
fn rnd(seed: u32, i: u32) -> f32 {
|
||||
let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b);
|
||||
h ^= h >> 13;
|
||||
h = h.wrapping_mul(0xc2b2_ae35);
|
||||
h ^= h >> 16;
|
||||
(h & 0xffff) as f32 / 65535.0
|
||||
}
|
||||
|
||||
fn noise(l: &mut Layer, r: Rect, seed: u32, min_alpha: f32) {
|
||||
let s = l.surface_mut().unwrap();
|
||||
let fmt = s.format();
|
||||
let ch = fmt.channels();
|
||||
let data: Vec<f32> = (0..r.width() * r.height())
|
||||
.flat_map(|i| {
|
||||
(0..ch).map(move |c| {
|
||||
let v = rnd(seed + c as u32 * 7919, i);
|
||||
if fmt.alpha && c == ch - 1 { min_alpha + (1.0 - min_alpha) * v } else { v }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
s.write_region(r, &data);
|
||||
}
|
||||
|
||||
fn document(mode: ColorMode, depth: SampleType, selection: bool) -> (Document, LayerId) {
|
||||
let mut doc = Document::with_background("t", Size::new(48, 40), mode, depth, Color::rgba(0.7, 0.6, 0.5, 1.0));
|
||||
let fmt = doc.pixel_format();
|
||||
let mut target = Layer::raster("target", fmt);
|
||||
noise(&mut target, Rect::from_xywh(4, 2, 38, 34), 11, 0.2);
|
||||
target.opacity = 0.8;
|
||||
target.blend = BlendMode::Multiply;
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.surface.fill_rect(Rect::from_xywh(0, 0, 12, 12), &[0.3]);
|
||||
target.mask = Some(m);
|
||||
let id = target.id;
|
||||
let mut clip = Layer::raster("clipped", fmt);
|
||||
noise(&mut clip, Rect::from_xywh(20, 10, 14, 14), 17, 0.5);
|
||||
clip.clipped = true;
|
||||
clip.opacity = 0.6;
|
||||
let mut top = Layer::raster("top", fmt);
|
||||
noise(&mut top, Rect::from_xywh(0, 24, 48, 16), 23, 0.0);
|
||||
top.blend = BlendMode::Screen;
|
||||
doc.layers.extend([target, clip, top]);
|
||||
if selection {
|
||||
let mut sel = photocraft_raster::Surface::new(PixelFormat::GRAY8);
|
||||
sel.fill_rect(Rect::from_xywh(8, 4, 30, 26), &[1.0]);
|
||||
sel.fill_rect(Rect::from_xywh(8, 4, 8, 26), &[0.5]);
|
||||
doc.selection = Some(sel);
|
||||
}
|
||||
(doc, id)
|
||||
}
|
||||
|
||||
fn sample(kind: &str) -> Value {
|
||||
match kind {
|
||||
"brightnessContrast" => json!({"brightness": 40, "contrast": 30}),
|
||||
"levels" => json!({"inBlack": 30, "inWhite": 220, "gamma": 1.3, "outBlack": 10}),
|
||||
"curves" => json!({"points": [[0, 20], [90, 150], [255, 230]]}),
|
||||
"exposure" => json!({"exposure": 0.8, "offset": 0.02, "gamma": 0.9}),
|
||||
"vibrance" => json!({"vibrance": 50, "saturation": 20}),
|
||||
"hueSaturation" => json!({"hue": 25, "saturation": 30, "reds": {"hue": 40}}),
|
||||
"colorBalance" => json!({"midtones": [40, 0, -20], "shadows": [0, 20, 0]}),
|
||||
"blackWhite" => json!({"reds": 150, "tint": true}),
|
||||
"photoFilter" => json!({"filter": "cooling80", "density": 60}),
|
||||
"channelMixer" => json!({"red": [50, 50, 0, 0], "blue": [0, 20, 90, 5]}),
|
||||
"posterize" => json!({"levels": 3}),
|
||||
"threshold" => json!({"level": 90}),
|
||||
"gradientMap" => json!({"stops": [[0, "#200040"], [1, "#ffd080"]]}),
|
||||
_ => json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn pm(p: &[f32; 4]) -> [f32; 4] {
|
||||
[p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]
|
||||
}
|
||||
|
||||
fn diffs(a: &[[f32; 4]], b: &[[f32; 4]]) -> Vec<f32> {
|
||||
a.iter().zip(b).map(|(x, y)| (0..4).map(|c| (pm(x)[c] - pm(y)[c]).abs()).fold(0.0, f32::max)).collect()
|
||||
}
|
||||
|
||||
/// Largest difference, ignoring up to `skip` outliers.
|
||||
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]], skip: usize) -> f32 {
|
||||
let mut d = diffs(a, b);
|
||||
d.sort_by(|x, y| y.total_cmp(x));
|
||||
d.get(skip).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_layer_composites_on_the_gpu_like_the_command() {
|
||||
let Some((device, queue, mut comp)) = gpu() else { return };
|
||||
// GPU vs CPU of the same document (as the compositor parity tests), and GPU preview vs the
|
||||
// command's CPU result (adds the command's rounding to the layer depth).
|
||||
let (tol_gpu, tol_cmd) = (2.0 / 255.0, 2.5 / 255.0);
|
||||
let mut worst = 0.0f32;
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for selection in [false, true] {
|
||||
let (doc, target) = document(mode, depth, selection);
|
||||
for kind in photocraft_ui_egui::adjust_editors::KINDS {
|
||||
let params = sample(kind);
|
||||
let Ok(preview) = preview_document(&doc, target, kind, ¶ms) else {
|
||||
// Grayscale kinds that make colour preview on the CPU proxy.
|
||||
assert_eq!(mode, ColorMode::Grayscale, "{kind}");
|
||||
continue;
|
||||
};
|
||||
assert!(preview.layer(PREVIEW_LAYER).is_some());
|
||||
let what = format!("{kind} {mode:?} {depth:?} selection {selection}");
|
||||
let b = preview.bounds();
|
||||
// The GPU holds 16- and 32-bit layers as half floats: Threshold and Posterize
|
||||
// may flip a few pixels sitting right at a step (1 % allowed).
|
||||
let skip = if matches!(kind, "threshold" | "posterize") && depth != SampleType::U8 { (b.width() * b.height()) as usize / 100 } else { 0 };
|
||||
let on_gpu = render_to_vec(&mut comp, &device, &queue, &preview, b).unwrap_or_else(|e| panic!("{what}: {e:?}"));
|
||||
let on_cpu = photocraft_compose::render(&preview, b).px;
|
||||
let d = max_diff(&on_gpu, &on_cpu, skip);
|
||||
assert!(d <= tol_gpu, "{what}: GPU vs CPU {:.2}/255", d * 255.0);
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.add_document(doc.clone(), None);
|
||||
s.select_layer(target).unwrap();
|
||||
s.execute(&format!("image.adjustments.{kind}"), params.clone()).unwrap();
|
||||
let cmd = photocraft_compose::render(&s.active().unwrap().doc, b).px;
|
||||
let d = max_diff(&on_gpu, &cmd, skip);
|
||||
worst = worst.max(d);
|
||||
assert!(d <= tol_cmd, "{what}: GPU preview vs command {:.2}/255", d * 255.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("worst GPU preview vs command: {:.3}/255", worst * 255.0);
|
||||
}
|
||||
|
||||
/// The zoomed-out proxy preview (own document and layer ids) composites on the GPU like the CPU.
|
||||
#[test]
|
||||
fn proxy_preview_composites_on_the_gpu() {
|
||||
use photocraft_ui_egui::adjust_preview::{base_document, proxy_base, proxy_with_settings};
|
||||
let Some((device, queue, mut comp)) = gpu() else { return };
|
||||
for selection in [false, true] {
|
||||
let (doc, target) = document(ColorMode::Rgb, SampleType::U8, selection);
|
||||
let proxy = proxy_base(&base_document(&doc, target).unwrap(), 2);
|
||||
// Render the full document too, so both share the compositor's resident textures.
|
||||
render_to_vec(&mut comp, &device, &queue, &doc, doc.bounds()).unwrap();
|
||||
for kind in photocraft_ui_egui::adjust_editors::KINDS {
|
||||
let p = proxy_with_settings(&proxy, kind, &sample(kind)).unwrap();
|
||||
let on_gpu = render_to_vec(&mut comp, &device, &queue, &p, p.bounds()).unwrap();
|
||||
let on_cpu = photocraft_compose::render(&p, p.bounds()).px;
|
||||
let d = max_diff(&on_gpu, &on_cpu, 0);
|
||||
assert!(d <= 2.0 / 255.0, "{kind} selection {selection}: {:.2}/255", d * 255.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user