From 0e5d8cc36c87dd7d9d8137f0f2aa860c42f0a2a9 Mon Sep 17 00:00:00 2001 From: Astrid Date: Thu, 3 Sep 2026 23:45:16 +0000 Subject: [PATCH] feat(raw): add non-destructive development workflow --- README.md | 6 +- crates/app/src/dialogs/filters.rs | 53 +++- crates/app/src/native_menu.rs | 12 +- crates/app/src/panels/menu_bar.rs | 7 +- crates/app/src/panels/menus.rs | 16 + crates/app/src/workspace/adjustments.rs | 10 +- crates/app/src/workspace/docs.rs | 17 + crates/app/src/workspace/filters.rs | 393 +++++++++++++++++++++++- crates/app/src/workspace/mod.rs | 11 +- crates/codec-psd/src/lib.rs | 1 + crates/codec-psd/src/raw.rs | 171 +++++++++++ crates/codec-psd/src/reader/layers.rs | 34 +- crates/codec-psd/src/writer/mod.rs | 21 ++ crates/codec-psd/tests/writer.rs | 76 +++++ crates/core/src/document.rs | 59 ++++ crates/core/src/history.rs | 6 + crates/core/src/layer.rs | 5 + crates/core/src/lib.rs | 2 + crates/core/src/raw.rs | 120 ++++++++ docs/web.md | 6 +- plugins/codecs-common/src/raw.rs | 161 +++++++++- 21 files changed, 1137 insertions(+), 50 deletions(-) create mode 100644 crates/codec-psd/src/raw.rs create mode 100644 crates/core/src/raw.rs diff --git a/README.md b/README.md index 903110c7..8df26335 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,11 @@ byte-for-byte, so a round trip never loses work. **Smart objects** keep their source pixels, so transforming one repeatedly costs no more quality than transforming it once. Also PNG, JPEG, WebP and TIFF, plus HEIC/HEIF import (iPhone photos) and camera raw import: NEF, ARW, CR2, DNG, RAF, ORF, RW2, PEF, SRW and the -rest, developed with the camera's white balance to 16-bit sRGB. Raws +rest. A capture opens in Camera Raw with sensor-domain white balance and +exposure, live fast-demosaic previews and a best-quality render on Apply; +the original capture and all 15 development settings stay attached to the +layer and survive PSD/PSB save and reopen, so adjustments never compound on +the previously developed pixels. The rendered document remains 16-bit sRGB. Raws decode through Schist's own clean-room `schist-codec-raw` crate (pure Rust, written from the public specifications and verified sample for sample against LibRaw across some 250 camera files), covering every diff --git a/crates/app/src/dialogs/filters.rs b/crates/app/src/dialogs/filters.rs index 273fb240..5a5b4dce 100644 --- a/crates/app/src/dialogs/filters.rs +++ b/crates/app/src/dialogs/filters.rs @@ -12,12 +12,16 @@ pub(super) fn filter_dialog( map: Option>, cx: &mut Context, ) -> impl IntoElement { - let (name, specs) = ws + let raw_development = ws.is_raw_redevelopment(id); + let (mut name, specs) = ws .registry .filters() .find(|f| f.id() == id) .map(|f| (f.name().to_string(), f.params())) .unwrap_or_else(|| (id.to_string(), Vec::new())); + if raw_development { + name = "Camera Raw Development".to_string(); + } // Scrolls, because Custom is a five-by-five kernel and Lighting // Effects has a dozen sliders: a filter dialog is a list of whatever @@ -61,6 +65,34 @@ pub(super) fn filter_dialog( cx, )); } + if raw_development { + body = body.child(ui::button( + "Reset to As Shot", + false, + move |ws, _window, cx| { + let Some(filter) = ws.registry.filters().find(|filter| filter.id() == id) else { + return; + }; + let defaults = schist_plugin_api::FilterValues::defaults(&filter.params()); + let mut next = None; + ws.update_modal(|modal| { + if let Modal::Filter { + values, preview, .. + } = modal + { + *values = defaults.clone(); + if *preview { + next = Some(values.clone()); + } + } + }); + if let Some(values) = next { + ws.preview_filter(id, Some(&values), cx); + } + }, + cx, + )); + } // A filter that takes an image gets a row to choose one with. This // is Photoshop's "Choose a displacement map" dialog, except that it // opens from inside the filter rather than in front of it, so the @@ -149,7 +181,11 @@ pub(super) fn filter_dialog( div() .text_size(px(11.0)) .text_color(gpui::rgb(ui::palette().text_dim)) - .child("Applies to the active layer, inside the selection."), + .child(if raw_development { + "Re-develops the active layer from its original sensor data." + } else { + "Applies to the active layer, inside the selection." + }), ); let apply_values = values.clone(); @@ -167,8 +203,17 @@ pub(super) fn filter_dialog( "OK", true, move |ws, _w, cx| { - ws.apply_filter(id, &apply_values, cx); - ws.close_modal(cx); + // A RAW-backed layer renders asynchronously. Close its + // preview dialog first, then let `apply_filter` replace it + // with a progress modal; the ordinary pixel-filter path + // remains synchronous and closes afterwards. + if ws.is_raw_redevelopment(id) { + ws.close_modal(cx); + ws.apply_filter(id, &apply_values, cx); + } else { + ws.apply_filter(id, &apply_values, cx); + ws.close_modal(cx); + } }, cx, )); diff --git a/crates/app/src/native_menu.rs b/crates/app/src/native_menu.rs index d8be6a88..565d7bad 100644 --- a/crates/app/src/native_menu.rs +++ b/crates/app/src/native_menu.rs @@ -36,7 +36,7 @@ pub fn sync(ws: &mut Workspace, cx: &mut Context) { fn signature(ws: &Workspace) -> String { let v = &ws.view; let mut out = format!( - "{}{}{}{}{}{}{}", + "{}{}{}{}{}{}{}{}", v.rulers as u8, v.grid as u8, v.guides as u8, @@ -45,6 +45,9 @@ fn signature(ws: &Workspace) -> String { ws.color.proof.is_some() as u8, // The gallery swaps the whole menu set out. ws.gallery_open() as u8, + // Camera Raw changes its label when the active layer carries an + // original capture, so switching documents/layers must rebuild it. + ws.is_raw_redevelopment("filter.camera_raw") as u8, ); if let Some(doc) = ws.doc.as_ref() { for comp in &doc.layer_comps { @@ -133,12 +136,7 @@ fn item(ws: &Workspace, entry: MenuEntry) -> Option { }), ), MenuEntry::Filter(id) => { - let name = ws - .registry - .filters() - .find(|f| f.id() == id) - .map(|f| format!("{}…", f.name())) - .unwrap_or_else(|| id.to_string()); + let name = panels::filter_menu_label(ws, id); action_item(name, Box::new(OpenFilter { id: id.to_string() })) } MenuEntry::Dynamic(label, item) => action_item(label, Box::new(RunAppItem { item })), diff --git a/crates/app/src/panels/menu_bar.rs b/crates/app/src/panels/menu_bar.rs index 5d2f87fb..0339ff1b 100644 --- a/crates/app/src/panels/menu_bar.rs +++ b/crates/app/src/panels/menu_bar.rs @@ -508,12 +508,7 @@ pub(super) fn menu_entry_row( ) .into_any_element(), MenuEntry::Filter(id) => { - let name = ws - .registry - .filters() - .find(|f| f.id() == id) - .map(|f| format!("{}…", f.name())) - .unwrap_or_else(|| id.to_string()); + let name = filter_menu_label(ws, id); menu_row( name, String::new(), diff --git a/crates/app/src/panels/menus.rs b/crates/app/src/panels/menus.rs index 3f15c449..2e5bdd95 100644 --- a/crates/app/src/panels/menus.rs +++ b/crates/app/src/panels/menus.rs @@ -20,6 +20,22 @@ pub(crate) enum MenuEntry { Sep, } +/// A RAW-backed layer uses Camera Raw as a non-destructive development +/// workflow; everywhere else it remains the ordinary destructive filter. +/// Keep the menu label in step with the dialog title for both menu-bar +/// implementations. +pub(crate) fn filter_menu_label(ws: &Workspace, id: &str) -> String { + if ws.is_raw_redevelopment(id) { + "Camera Raw Development…".to_string() + } else { + ws.registry + .filters() + .find(|filter| filter.id() == id) + .map(|filter| format!("{}…", filter.name())) + .unwrap_or_else(|| id.to_string()) + } +} + pub(crate) fn menus(ws: &Workspace) -> Vec<(&'static str, Vec)> { use AppItem::*; use MenuEntry::*; diff --git a/crates/app/src/workspace/adjustments.rs b/crates/app/src/workspace/adjustments.rs index 583864e4..5bb7716d 100644 --- a/crates/app/src/workspace/adjustments.rs +++ b/crates/app/src/workspace/adjustments.rs @@ -8,13 +8,19 @@ impl Workspace { let Some(filter) = self.registry.filters().find(|f| f.id() == id) else { return; }; - let values = schist_plugin_api::FilterValues::defaults(&filter.params()); + let mut values = schist_plugin_api::FilterValues::defaults(&filter.params()); + self.seed_raw_filter_values(id, &mut values); // Filters with no parameters just run. if values.0.is_empty() { self.apply_filter(id, &values, cx); return; } - if !self.begin_filter_preview() { + let begun = if self.is_raw_redevelopment(id) { + self.begin_raw_filter_preview() + } else { + self.begin_filter_preview() + }; + if !begun { cx.notify(); return; } diff --git a/crates/app/src/workspace/docs.rs b/crates/app/src/workspace/docs.rs index b3fa3663..21779ec6 100644 --- a/crates/app/src/workspace/docs.rs +++ b/crates/app/src/workspace/docs.rs @@ -368,6 +368,20 @@ impl Workspace { let _ = &path; match result { Ok(doc) => { + // A capture opens into its development workflow. A PSD/PSB + // that happens to contain a RAW-backed layer does not: it is + // already an edited document and should reopen undisturbed. + let raw_capture = path + .extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase) + .is_some_and(|ext| { + schist_codecs_common::raw::RAW_EXTENSIONS.contains(&ext.as_str()) + }) + && doc + .active_layer + .and_then(|id| doc.tree.find(id)) + .is_some_and(|layer| layer.raw.is_some()); self.status = match &doc.path { Some(p) => format!("Opened {}", p.display()).into(), None => format!("Opened {}", doc.title).into(), @@ -378,6 +392,9 @@ impl Workspace { #[cfg(not(target_arch = "wasm32"))] self.finish_load_bookkeeping(&path); self.offer_missing_fonts(cx); + if raw_capture { + self.open_filter_dialog("filter.camera_raw", cx); + } } // A HEIC on a machine with no libheif — or a libheif with // no HEVC decoder, as stock Ubuntu ships: downloading the diff --git a/crates/app/src/workspace/filters.rs b/crates/app/src/workspace/filters.rs index 3de96a66..77790266 100644 --- a/crates/app/src/workspace/filters.rs +++ b/crates/app/src/workspace/filters.rs @@ -3,9 +3,114 @@ use super::*; +const CAMERA_RAW_FILTER: &str = "filter.camera_raw"; +const RAW_PREVIEW_DEBOUNCE_MS: u64 = 120; + +fn settings_from_values(values: &schist_plugin_api::FilterValues) -> schist_core::RawSettings { + schist_core::RawSettings { + temperature: values.get("temperature"), + tint: values.get("tint"), + exposure: values.get("exposure"), + contrast: values.get("contrast"), + highlights: values.get("highlights"), + shadows: values.get("shadows"), + whites: values.get("whites"), + blacks: values.get("blacks"), + clarity: values.get("clarity"), + dehaze: values.get("dehaze"), + vibrance: values.get("vibrance"), + saturation: values.get("saturation"), + sharpening: values.get("sharpening"), + noise: values.get("noise"), + vignette: values.get("vignette"), + } + .sanitized() +} + +fn values_from_settings( + settings: schist_core::RawSettings, + values: &mut schist_plugin_api::FilterValues, +) { + let settings = settings.sanitized(); + for (key, value) in [ + ("temperature", settings.temperature), + ("tint", settings.tint), + ("exposure", settings.exposure), + ("contrast", settings.contrast), + ("highlights", settings.highlights), + ("shadows", settings.shadows), + ("whites", settings.whites), + ("blacks", settings.blacks), + ("clarity", settings.clarity), + ("dehaze", settings.dehaze), + ("vibrance", settings.vibrance), + ("saturation", settings.saturation), + ("sharpening", settings.sharpening), + ("noise", settings.noise), + ("vignette", settings.vignette), + ] { + values.set(key, value); + } +} + +/// Develop the sensor-domain controls and then run the remaining Camera Raw +/// controls over that fresh render. The three controls already consumed by +/// the RAW pipeline are zeroed so they are not applied twice. +fn render_raw_capture( + source: Arc<[u8]>, + settings: schist_core::RawSettings, + quality: schist_codecs_common::raw::RawQuality, + filter: Arc, + mut values: schist_plugin_api::FilterValues, +) -> anyhow::Result { + let mut developed = schist_codecs_common::raw::develop_rgba(&source, settings, quality)?; + values.set("temperature", 0.0); + values.set("tint", 0.0); + values.set("exposure", 0.0); + filter.apply( + &mut developed.rgba, + developed.width, + developed.height, + &values, + ); + Ok(developed) +} + impl Workspace { // ----- filters and adjustments ----- + /// Fill Camera Raw controls from the development attached to the active + /// layer. Ordinary pixel layers retain the filter's declared defaults. + pub(super) fn seed_raw_filter_values( + &self, + id: &str, + values: &mut schist_plugin_api::FilterValues, + ) { + if id != CAMERA_RAW_FILTER { + return; + } + let settings = self + .doc + .as_ref() + .and_then(|doc| doc.active_layer.and_then(|id| doc.tree.find(id))) + .and_then(|layer| layer.raw.as_deref()) + .map(|raw| raw.settings); + if let Some(settings) = settings { + values_from_settings(settings, values); + } + } + + /// Whether Camera Raw means re-developing the active layer's original + /// capture rather than destructively filtering its current pixels. + pub(crate) fn is_raw_redevelopment(&self, id: &str) -> bool { + id == CAMERA_RAW_FILTER + && self + .doc + .as_ref() + .and_then(|doc| doc.active_layer.and_then(|id| doc.tree.find(id))) + .is_some_and(|layer| layer.raw.is_some()) + } + /// Run a registered filter over the active layer, confined to the /// selection, as one undoable edit. /// The pixels a filter would touch: the layer's content clipped to the @@ -246,9 +351,23 @@ impl Workspace { filtered: &[f32], label: &str, record: bool, + ) { + self.write_region_inner(layer_id, region, original, filtered, label, record, true); + } + + #[allow(clippy::too_many_arguments)] + fn write_region_inner( + &mut self, + layer_id: schist_core::LayerId, + region: IntRect, + original: &[f32], + filtered: &[f32], + label: &str, + record: bool, + respect_selection: bool, ) { let Some(doc) = self.doc.as_mut() else { return }; - let selection = doc.selection.clone(); + let selection = respect_selection.then(|| doc.selection.clone()); let depth = doc.depth; let coords: Vec = TileCoord::covering(®ion).collect(); @@ -262,7 +381,15 @@ impl Workspace { let Some(tile) = edit.writable_tile(layer_id, coord) else { break; }; - blend_region_tile(tile, coord, clip, region, original, filtered, &selection); + blend_region_tile( + tile, + coord, + clip, + region, + original, + filtered, + selection.as_ref(), + ); } edit.commit(); } else { @@ -275,7 +402,15 @@ impl Workspace { continue; } let tile = raster.tiles.get_mut_or_insert(coord, depth); - blend_region_tile(tile, coord, clip, region, original, filtered, &selection); + blend_region_tile( + tile, + coord, + clip, + region, + original, + filtered, + selection.as_ref(), + ); } doc.add_damage(region); } @@ -285,6 +420,14 @@ impl Workspace { /// be re-run from the original on every slider tick and undone on /// cancel. Returns false when there is nothing to filter. pub fn begin_filter_preview(&mut self) -> bool { + self.begin_filter_preview_for(false) + } + + pub(super) fn begin_raw_filter_preview(&mut self) -> bool { + self.begin_filter_preview_for(true) + } + + fn begin_filter_preview_for(&mut self, whole_layer: bool) -> bool { self.filter_preview = None; let Some(layer_id) = self.doc.as_ref().and_then(|d| d.active_layer) else { self.status = "Select a layer first".into(); @@ -300,7 +443,14 @@ impl Workspace { self.status = "Filters need a pixel layer".into(); return false; } - let region = self.filter_region(layer_id); + // A RAW development is the whole capture. A selection still applies + // when Camera Raw is used as an ordinary destructive pixel filter, + // but cannot sensibly crop the sensor pipeline itself. + let region = if whole_layer { + self.doc.as_ref().unwrap().canvas_rect() + } else { + self.filter_region(layer_id) + }; if region.is_empty() { self.status = "Nothing to filter".into(); return false; @@ -312,10 +462,115 @@ impl Workspace { layer: layer_id, region, original, + whole_layer, }); true } + fn preview_raw_filter( + &mut self, + values: &schist_plugin_api::FilterValues, + cx: &mut Context, + ) { + let Some(preview) = self.filter_preview.clone() else { + return; + }; + let Some(raw) = self + .doc + .as_ref() + .and_then(|doc| doc.tree.find(preview.layer)) + .and_then(|layer| layer.raw.as_deref()) + .cloned() + else { + return; + }; + let Some(filter) = self.registry.shared_filter(CAMERA_RAW_FILTER) else { + return; + }; + let settings = settings_from_values(values); + let values = values.clone(); + self.raw_preview_seq = self.raw_preview_seq.wrapping_add(1); + let sequence = self.raw_preview_seq; + self.status = "Developing RAW preview…".into(); + cx.notify(); + + cx.spawn(async move |this, cx| { + // Slider drags emit many positions. Only start the expensive + // sensor decode once a position has remained current briefly. + cx.background_executor() + .timer(std::time::Duration::from_millis(RAW_PREVIEW_DEBOUNCE_MS)) + .await; + let current = this + .update(cx, |ws, _cx| { + ws.raw_preview_seq == sequence + && ws + .filter_preview + .as_ref() + .is_some_and(|p| p.layer == preview.layer) + }) + .unwrap_or(false); + if !current { + return; + } + + let rendered = cx + .background_executor() + .spawn(async move { + render_raw_capture( + raw.source, + settings, + schist_codecs_common::raw::RawQuality::Fast, + filter, + values, + ) + }) + .await; + this.update(cx, |ws, cx| { + if ws.raw_preview_seq != sequence { + return; + } + let Some(current) = ws.filter_preview.clone() else { + return; + }; + if current.layer != preview.layer { + return; + } + match rendered { + Ok(developed) + if developed.width == current.region.width() as usize + && developed.height == current.region.height() as usize => + { + ws.write_region_inner( + current.layer, + current.region, + ¤t.original, + &developed.rgba, + "", + false, + false, + ); + ws.status = "RAW preview (fast demosaic)".into(); + ws.after_change(cx); + } + Ok(developed) => { + ws.status = format!( + "RAW preview size changed: {} × {}", + developed.width, developed.height + ) + .into(); + cx.notify(); + } + Err(err) => { + ws.status = format!("RAW preview failed: {err}").into(); + cx.notify(); + } + } + }) + .ok(); + }) + .detach(); + } + /// Re-run the filter on the canvas from the snapshot, without touching /// history. `None` values restore the untouched pixels. pub fn preview_filter( @@ -327,6 +582,18 @@ impl Workspace { let Some(preview) = self.filter_preview.clone() else { return; }; + if let Some(values) = values { + if id == CAMERA_RAW_FILTER + && self + .doc + .as_ref() + .and_then(|doc| doc.tree.find(preview.layer)) + .is_some_and(|layer| layer.raw.is_some()) + { + self.preview_raw_filter(values, cx); + return; + } + } let mut buf = preview.original.clone(); if let Some(values) = values { let (w, h) = ( @@ -348,19 +615,21 @@ impl Workspace { ); filter.apply_with(&mut buf, w, h, values, &context); } - self.write_region( + self.write_region_inner( preview.layer, preview.region, &preview.original, &buf, "", false, + !preview.whole_layer, ); self.after_change(cx); } /// Drop a preview, restoring the pixels it was drawn over. pub fn cancel_filter_preview(&mut self, cx: &mut Context) { + self.raw_preview_seq = self.raw_preview_seq.wrapping_add(1); if self.filter_preview.is_none() { return; } @@ -374,6 +643,14 @@ impl Workspace { values: &schist_plugin_api::FilterValues, cx: &mut Context, ) { + if self.is_raw_redevelopment(id) { + // Calls outside the dialog may still have a live preview. Put + // its pixels back and invalidate every in-flight preview before + // the Best-quality render starts. + self.cancel_filter_preview(cx); + self.apply_raw_development(values, cx); + return; + } // A live preview has already changed these pixels; put them back so // the recorded edit has the right "before". if self.filter_preview.is_some() { @@ -491,6 +768,112 @@ impl Workspace { self.after_change(cx); } + fn apply_raw_development( + &mut self, + values: &schist_plugin_api::FilterValues, + cx: &mut Context, + ) { + let Some(doc) = self.doc.as_ref() else { return }; + let Some(layer_id) = doc.active_layer else { + return; + }; + let Some(raw) = doc + .tree + .find(layer_id) + .and_then(|layer| layer.raw.as_deref()) + .cloned() + else { + return; + }; + let Some(filter) = self.registry.shared_filter(CAMERA_RAW_FILTER) else { + return; + }; + let document_id = doc.id; + let settings = settings_from_values(values); + let values = values.clone(); + let source = raw.source.clone(); + self.raw_preview_seq = self.raw_preview_seq.wrapping_add(1); + self.open_modal( + Modal::Busy { + title: "Camera Raw".into(), + what: "Developing the original capture…".into(), + note: "Using the best demosaic path. The original RAW and these settings remain editable after saving as PSD or PSB." + .into(), + }, + cx, + ); + + cx.spawn(async move |this, cx| { + let rendered = cx + .background_executor() + .spawn(async move { + render_raw_capture( + source, + settings, + schist_codecs_common::raw::RawQuality::Best, + filter, + values, + ) + }) + .await; + this.update(cx, |ws, cx| { + ws.modal = None; + let Some(doc) = ws.doc.as_mut() else { + return; + }; + if doc.id != document_id || doc.tree.find(layer_id).is_none() { + ws.status = "RAW development finished after its document changed".into(); + cx.notify(); + return; + } + let developed = match rendered { + Ok(developed) => developed, + Err(err) => { + ws.status = format!("RAW development failed: {err}").into(); + cx.notify(); + return; + } + }; + let Ok(width) = u32::try_from(developed.width) else { + ws.status = "RAW development is too wide".into(); + cx.notify(); + return; + }; + let Ok(height) = u32::try_from(developed.height) else { + ws.status = "RAW development is too tall".into(); + cx.notify(); + return; + }; + if (width, height) != (doc.width, doc.height) { + ws.status = format!( + "RAW development size changed to {width} × {height}; keeping the current layer" + ) + .into(); + cx.notify(); + return; + } + + let mut tiles = schist_core::TileMap::default(); + schist_core::blit_rgba_f32( + &mut tiles, + doc.depth, + IntRect::from_size(width, height), + &developed.rgba, + ); + let mut after = raw; + after.settings = settings; + let mut edit = doc.begin_edit("Camera Raw Development"); + edit.replace_layer_tiles(layer_id, tiles); + edit.set_raw_development(layer_id, Some(Box::new(after))); + edit.commit(); + ws.status = "Camera Raw development applied (best demosaic)".into(); + ws.after_change(cx); + }) + .ok(); + }) + .detach(); + } + /// Image Size through a neural upscaler. /// /// The network costs seconds per input megapixel, so it runs on a diff --git a/crates/app/src/workspace/mod.rs b/crates/app/src/workspace/mod.rs index e6a96b0c..ddf0417b 100644 --- a/crates/app/src/workspace/mod.rs +++ b/crates/app/src/workspace/mod.rs @@ -282,6 +282,9 @@ pub struct Workspace { /// Which colour control is being dragged, if any. pub picker_drag: Option, pub filter_preview: Option, + /// Generation of the most recently requested sensor-data preview. + /// Slow results from an older slider position are discarded on arrival. + raw_preview_seq: u64, /// Live bounds of slider tracks, recorded each frame by their canvases. slider_bounds: FxHashMap<&'static str, Bounds>, /// Slider drag in progress: (slider id, value before the drag) — used @@ -658,6 +661,9 @@ pub struct FilterPreview { pub layer: schist_core::LayerId, pub region: IntRect, pub original: Vec, + /// RAW development always covers the capture, independent of a pixel + /// selection. Ordinary filters leave this false. + pub whole_layer: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1304,6 +1310,7 @@ impl Workspace { curve_drag: None, picker_drag: None, filter_preview: None, + raw_preview_seq: 0, slider_bounds: FxHashMap::default(), active_slider: None, thumbs: FxHashMap::default(), @@ -1510,13 +1517,13 @@ fn blend_region_tile( region: IntRect, original: &[f32], filtered: &[f32], - selection: &schist_core::Selection, + selection: Option<&schist_core::Selection>, ) { let trect = coord.rect(); let w = region.width() as usize; for y in clip.top..clip.bottom { for x in clip.left..clip.right { - let cov = selection.coverage(x, y) as f32 / 255.0; + let cov = selection.map_or(1.0, |selection| selection.coverage(x, y) as f32 / 255.0); if cov <= 0.0 { continue; } diff --git a/crates/codec-psd/src/lib.rs b/crates/codec-psd/src/lib.rs index 560ed83b..34dfc92e 100644 --- a/crates/codec-psd/src/lib.rs +++ b/crates/codec-psd/src/lib.rs @@ -16,6 +16,7 @@ pub mod effects; pub mod error; +mod raw; mod reader; mod smart; pub mod vector; diff --git a/crates/codec-psd/src/raw.rs b/crates/codec-psd/src/raw.rs new file mode 100644 index 00000000..7178a0c0 --- /dev/null +++ b/crates/codec-psd/src/raw.rs @@ -0,0 +1,171 @@ +//! Schist camera-raw payloads in a private additional-layer-info block. +//! +//! PSD has no portable representation for an editable camera capture. +//! `ScRw` keeps the original file and Schist's development settings beside +//! the rendered layer pixels. Other PSD readers ignore the private block; +//! Schist can reopen it and render from the sensor data again. + +use schist_core::{Layer, RawDevelopment, RawSettings}; +use std::sync::Arc; + +/// Private block key: "Sc" for Schist, "Rw" for camera raw. +pub const RAW_BLOCK_KEY: [u8; 4] = *b"ScRw"; + +const VERSION: u32 = 1; +const SETTINGS_LEN: usize = 15; +/// A malformed block must not be able to make the reader allocate without +/// limit. This is well above the largest current still-camera capture. +pub(crate) const MAX_SOURCE_BYTES: usize = 1 << 30; + +/// Serialize a RAW-backed layer, or `None` for an ordinary layer. +pub fn write_raw(layer: &Layer) -> Option> { + let raw = layer.raw.as_deref()?; + if raw.source.is_empty() || raw.source.len() > MAX_SOURCE_BYTES { + return None; + } + let source_len = u32::try_from(raw.source.len()).ok()?; + let mut out = Vec::with_capacity(8 + SETTINGS_LEN * 4 + raw.source.len()); + out.extend_from_slice(&VERSION.to_be_bytes()); + out.extend_from_slice(&source_len.to_be_bytes()); + for value in settings_values(raw.settings.sanitized()) { + out.extend_from_slice(&value.to_be_bytes()); + } + out.extend_from_slice(&raw.source); + Some(out) +} + +/// Parse a private RAW payload. A malformed payload is ignored because the +/// PSD still contains the last rendered pixels for the layer. +pub fn read_raw(data: &[u8]) -> Option { + let mut cursor = Cursor { data, at: 0 }; + if cursor.u32()? != VERSION { + return None; + } + let source_len = cursor.u32()? as usize; + if source_len == 0 || source_len > MAX_SOURCE_BYTES { + return None; + } + let mut values = [0.0f32; SETTINGS_LEN]; + for value in &mut values { + *value = cursor.f32()?; + if !value.is_finite() { + return None; + } + } + let source = cursor.take(source_len)?; + if cursor.at != data.len() { + return None; + } + Some(RawDevelopment { + source: Arc::from(source), + settings: settings_from_values(values).sanitized(), + }) +} + +fn settings_values(s: RawSettings) -> [f32; SETTINGS_LEN] { + [ + s.temperature, + s.tint, + s.exposure, + s.contrast, + s.highlights, + s.shadows, + s.whites, + s.blacks, + s.clarity, + s.dehaze, + s.vibrance, + s.saturation, + s.sharpening, + s.noise, + s.vignette, + ] +} + +fn settings_from_values(v: [f32; SETTINGS_LEN]) -> RawSettings { + RawSettings { + temperature: v[0], + tint: v[1], + exposure: v[2], + contrast: v[3], + highlights: v[4], + shadows: v[5], + whites: v[6], + blacks: v[7], + clarity: v[8], + dehaze: v[9], + vibrance: v[10], + saturation: v[11], + sharpening: v[12], + noise: v[13], + vignette: v[14], + } +} + +struct Cursor<'a> { + data: &'a [u8], + at: usize, +} + +impl<'a> Cursor<'a> { + fn take(&mut self, count: usize) -> Option<&'a [u8]> { + let end = self.at.checked_add(count)?; + let bytes = self.data.get(self.at..end)?; + self.at = end; + Some(bytes) + } + + fn u32(&mut self) -> Option { + Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?)) + } + + fn f32(&mut self) -> Option { + Some(f32::from_be_bytes(self.take(4)?.try_into().ok()?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_payload_round_trips_source_and_settings() { + let mut layer = Layer::new_raster("capture"); + let settings = RawSettings { + temperature: 18.0, + tint: -7.0, + exposure: 1.25, + highlights: -32.0, + shadows: 41.0, + sharpening: 63.0, + ..RawSettings::default() + }; + layer.raw = Some(Box::new(RawDevelopment { + source: Arc::from(&b"raw camera bytes"[..]), + settings, + })); + + let payload = write_raw(&layer).expect("RAW payload"); + let decoded = read_raw(&payload).expect("valid RAW payload"); + assert_eq!(decoded.source.as_ref(), b"raw camera bytes"); + assert_eq!(decoded.settings, settings); + } + + #[test] + fn malformed_raw_payload_is_ignored() { + let mut layer = Layer::new_raster("capture"); + layer.raw = Some(Box::new(RawDevelopment { + source: Arc::from(&b"source"[..]), + settings: RawSettings::default(), + })); + let payload = write_raw(&layer).unwrap(); + + assert!(read_raw(&payload[..payload.len() - 1]).is_none()); + let mut trailing = payload.clone(); + trailing.push(0); + assert!(read_raw(&trailing).is_none()); + let mut nan = payload; + nan[8..12].copy_from_slice(&f32::NAN.to_be_bytes()); + assert!(read_raw(&nan).is_none()); + } +} diff --git a/crates/codec-psd/src/reader/layers.rs b/crates/codec-psd/src/reader/layers.rs index 2cabdfc1..596e23fd 100644 --- a/crates/codec-psd/src/reader/layers.rs +++ b/crates/codec-psd/src/reader/layers.rs @@ -659,6 +659,30 @@ fn make_layer( .find(|b| b.key == crate::smart::SMART_BLOCK_KEY) .and_then(|b| crate::smart::read_smart(&b.data, header.depth)) .map(Box::new); + let raw = rec + .extras + .iter() + .find(|b| b.key == crate::raw::RAW_BLOCK_KEY) + .and_then(|b| crate::raw::read_raw(&b.data)) + .map(Box::new); + let style = rec + .extras + .iter() + .find(|b| &b.key == b"lfx2") + .and_then(|b| crate::effects::read_lfx2(&b.data)) + .unwrap_or_default(); + // A valid ScRw payload can be very large and is now represented by + // `raw.source`; retaining the encoded block too would double memory for + // every reopened capture. Keep malformed or newer blocks verbatim so + // an unedited file still round-trips data this version cannot read. + let extras = if raw.is_some() { + rec.extras + .into_iter() + .filter(|block| block.key != crate::raw::RAW_BLOCK_KEY) + .collect() + } else { + rec.extras + }; Layer { id: LayerId::next(), @@ -674,13 +698,8 @@ fn make_layer( kind, // Effects: decoded so they render, and kept in `extras` too so a // file we never touch still round-trips byte-for-byte. - style: rec - .extras - .iter() - .find(|b| &b.key == b"lfx2") - .and_then(|b| crate::effects::read_lfx2(&b.data)) - .unwrap_or_default(), - extras: rec.extras, + style, + extras, // A shape layer's path, so the shape stays editable rather than // arriving as a flat picture of itself. The fill colour comes from // its 'SoCo' payload where there is one, and defaults to black. @@ -692,6 +711,7 @@ fn make_layer( // rasterization, and every further transform degraded it -- the // opposite of what smart objects are for. smart, + raw, styled: None, render_offset: (0, 0), } diff --git a/crates/codec-psd/src/writer/mod.rs b/crates/codec-psd/src/writer/mod.rs index 8387d94b..e3e15edb 100644 --- a/crates/codec-psd/src/writer/mod.rs +++ b/crates/codec-psd/src/writer/mod.rs @@ -65,6 +65,17 @@ pub fn write_psd_with(doc: &Document, psb: bool) -> Result, PsdError> { if psb { "PSB" } else { "PSD" } ))); } + if let Some(raw) = doc + .tree + .iter() + .filter_map(|layer| layer.raw.as_deref()) + .find(|raw| raw.source.is_empty() || raw.source.len() > crate::raw::MAX_SOURCE_BYTES) + { + return Err(PsdError::Unsupported(format!( + "camera-raw source of {} bytes cannot be embedded", + raw.source.len() + ))); + } let mode = match doc.mode { ColorMode::Rgb => MODE_RGB, @@ -481,6 +492,11 @@ fn build_extras(layer: &Layer, doc: &Document) -> Vec<([u8; 4], Vec)> { if block.key == crate::smart::SMART_BLOCK_KEY { continue; } + // Regenerated from `Layer::raw` below. Keeping an old copy would + // resurrect stale settings after the layer was edited. + if block.key == crate::raw::RAW_BLOCK_KEY { + continue; + } // Fill opacity is regenerated from the layer below, so a // preserved copy is stale: echoing it back wrote the file's // original value over whatever the user set in Schist. @@ -511,6 +527,11 @@ fn build_extras(layer: &Layer, doc: &Document) -> Vec<([u8; 4], Vec)> { if let Some(payload) = crate::smart::write_smart(layer) { out.push((crate::smart::SMART_BLOCK_KEY, payload)); } + // The immutable camera capture and its editable development settings. + // Other PSD readers ignore this private block and use the raster pixels. + if let Some(payload) = crate::raw::write_raw(layer) { + out.push((crate::raw::RAW_BLOCK_KEY, payload)); + } // 'iOpa' is one byte of fill opacity plus three of padding. Photoshop // omits the block at 100%, which is what a reader assumes when it is // absent, so only write it when it says something. diff --git a/crates/codec-psd/tests/writer.rs b/crates/codec-psd/tests/writer.rs index 92880e39..25d42897 100644 --- a/crates/codec-psd/tests/writer.rs +++ b/crates/codec-psd/tests/writer.rs @@ -893,6 +893,82 @@ fn an_ordinary_layer_gains_no_smart_object_block() { assert!(read_psd(&bytes).unwrap().tree.layers[0].smart.is_none()); } +#[test] +fn round_trips_original_raw_and_development_settings() { + let mut doc = base_doc(); + let mut layer = solid_layer( + "editable capture", + IntRect::from_xywh(0, 0, 64, 48), + [30, 40, 50, 255], + Depth::Eight, + ); + let settings = schist_core::RawSettings { + temperature: 24.0, + tint: -8.0, + exposure: 1.5, + highlights: -35.0, + shadows: 42.0, + sharpening: 55.0, + ..schist_core::RawSettings::default() + }; + layer.raw = Some(Box::new(schist_core::RawDevelopment { + source: std::sync::Arc::from(&b"complete original camera capture"[..]), + settings, + })); + doc.push_layer(layer); + + let bytes = write_psd(&doc).unwrap(); + assert!(bytes.windows(4).any(|window| window == b"ScRw")); + let back = read_psd(&bytes).unwrap(); + let raw = back.tree.layers[0] + .raw + .as_deref() + .expect("RAW backing should survive save and reopen"); + assert_eq!(raw.source.as_ref(), b"complete original camera capture"); + assert_eq!(raw.settings, settings); + assert!( + back.tree.layers[0] + .extras + .iter() + .all(|block| &block.key != b"ScRw"), + "the decoded source should not also remain duplicated in extras" + ); +} + +#[test] +fn an_ordinary_layer_gains_no_raw_block() { + let mut doc = base_doc(); + doc.push_layer(solid_layer( + "plain", + IntRect::from_xywh(0, 0, 8, 8), + [10, 20, 30, 255], + Depth::Eight, + )); + let bytes = write_psd(&doc).unwrap(); + assert!(!bytes.windows(4).any(|window| window == b"ScRw")); + assert!(read_psd(&bytes).unwrap().tree.layers[0].raw.is_none()); +} + +#[test] +fn an_invalid_raw_source_is_not_silently_dropped() { + let mut doc = base_doc(); + let mut layer = solid_layer( + "capture", + IntRect::from_xywh(0, 0, 8, 8), + [10, 20, 30, 255], + Depth::Eight, + ); + layer.raw = Some(Box::new(schist_core::RawDevelopment { + source: std::sync::Arc::from(&b""[..]), + settings: schist_core::RawSettings::default(), + })); + doc.push_layer(layer); + assert!( + write_psd(&doc).is_err(), + "saving must fail instead of losing the original capture" + ); +} + /// Fill opacity is what makes "Fill 0% plus a drop shadow" show only the /// shadow. The reader hard-coded it to 1.0 and left the 'iOpa' block in /// `extras`, so a Photoshop file with Fill 0% opened fully opaque; the diff --git a/crates/core/src/document.rs b/crates/core/src/document.rs index df5d98ac..b2057e84 100644 --- a/crates/core/src/document.rs +++ b/crates/core/src/document.rs @@ -418,6 +418,21 @@ impl Document { } self.structure_changed(); } + EditOp::RawDevelopmentSet { + layer, + before, + after, + } => { + let want = if dir == Direction::Undo { + before + } else { + after + }; + if let Some(l) = self.tree.find_mut(*layer) { + l.raw = want.clone(); + } + self.structure_changed(); + } EditOp::LayerStyleSet { layer, before, @@ -757,6 +772,27 @@ impl<'a> EditBuilder<'a> { }); } + /// Attach, replace or clear the original capture and settings behind a + /// camera-raw layer. + pub fn set_raw_development( + &mut self, + layer: LayerId, + after: Option>, + ) { + let before = self.doc.tree.find(layer).and_then(|l| l.raw.clone()); + if before == after { + return; + } + if let Some(l) = self.doc.tree.find_mut(layer) { + l.raw = after.clone(); + } + self.ops.push(EditOp::RawDevelopmentSet { + layer, + before, + after, + }); + } + /// Record a change to a layer's effects. pub fn record_layer_style( &mut self, @@ -1520,4 +1556,27 @@ mod tests { "the document holds b and the disk holds a, so it is not saved" ); } + + #[test] + fn raw_development_settings_undo_and_redo() { + let mut doc = Document::new("raw", 8, 8, Depth::Sixteen); + let id = doc.push_layer(Layer::new_raster("capture")); + let original = crate::RawDevelopment { + source: std::sync::Arc::from(&b"original capture"[..]), + settings: crate::RawSettings::default(), + }; + doc.tree.find_mut(id).unwrap().raw = Some(Box::new(original.clone())); + let mut changed = original.clone(); + changed.settings.exposure = 1.25; + + let mut edit = doc.begin_edit("Camera Raw Development"); + edit.set_raw_development(id, Some(Box::new(changed.clone()))); + assert!(edit.commit()); + assert_eq!(doc.tree.find(id).unwrap().raw.as_deref(), Some(&changed)); + + assert_eq!(doc.undo().as_deref(), Some("Camera Raw Development")); + assert_eq!(doc.tree.find(id).unwrap().raw.as_deref(), Some(&original)); + assert_eq!(doc.redo().as_deref(), Some("Camera Raw Development")); + assert_eq!(doc.tree.find(id).unwrap().raw.as_deref(), Some(&changed)); + } } diff --git a/crates/core/src/history.rs b/crates/core/src/history.rs index bbd8fe90..aecc8428 100644 --- a/crates/core/src/history.rs +++ b/crates/core/src/history.rs @@ -111,6 +111,12 @@ pub enum EditOp { before: Vec, after: Vec, }, + /// A layer's original camera capture or development settings changed. + RawDevelopmentSet { + layer: LayerId, + before: Option>, + after: Option>, + }, /// A layer's effects changed. LayerStyleSet { layer: LayerId, diff --git a/crates/core/src/layer.rs b/crates/core/src/layer.rs index 1c020a53..18b6ef7d 100644 --- a/crates/core/src/layer.rs +++ b/crates/core/src/layer.rs @@ -258,6 +258,10 @@ pub struct Layer { /// rendered pixels, but they are derived from here and are re-rendered /// from the source whenever the transform changes. pub smart: Option>, + /// Set when this raster is a development of an original camera capture. + /// The pixels are a render cache; Camera Raw can rebuild them from this + /// immutable source without compounding earlier adjustments. + pub raw: Option>, /// The layer's pixels with `style` rasterized around them, rebuilt by /// `schist-layer-fx` whenever the pixels or the style change. This /// is a derived cache: never saved, never compared, and dropped as @@ -292,6 +296,7 @@ impl Layer { shape_key: 0, is_frame: false, smart: None, + raw: None, styled: None, render_offset: (0, 0), } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 87fb3daa..b8489021 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -11,6 +11,7 @@ pub mod geom; pub mod history; pub mod layer; pub mod path; +pub mod raw; pub mod resample; pub mod selection; pub mod smart; @@ -32,6 +33,7 @@ pub use layer::{ LayerTree, RasterLayer, RawBlock, StyledRaster, }; pub use path::{Anchor, SubPath, VectorPath, VectorShape}; +pub use raw::{RawDevelopment, RawSettings}; pub use resample::{Affine, Filter}; pub use selection::{SelectOp, Selection}; pub use smart::SmartObject; diff --git a/crates/core/src/raw.rs b/crates/core/src/raw.rs new file mode 100644 index 00000000..7007590c --- /dev/null +++ b/crates/core/src/raw.rs @@ -0,0 +1,120 @@ +//! Non-destructive camera-raw source and development settings. +//! +//! The kernel does not decode camera files. It only carries the immutable +//! capture and the settings used to render a raster layer from it, in the +//! same way [`crate::SmartObject`] carries the source behind rendered smart +//! object pixels. Codecs and the app own the actual development pipeline. + +use std::sync::Arc; + +/// The editable controls for a camera-raw development. +/// +/// Values use the ranges presented by the Camera Raw dialog: exposure is +/// measured in EV, sharpening is 0..=150, and the other controls are +/// generally -100..=100 (noise reduction is 0..=100). Keeping a typed, +/// fixed layout makes the document format stable even if the UI is moved or +/// the filter plug-in is unavailable. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct RawSettings { + pub temperature: f32, + pub tint: f32, + pub exposure: f32, + pub contrast: f32, + pub highlights: f32, + pub shadows: f32, + pub whites: f32, + pub blacks: f32, + pub clarity: f32, + pub dehaze: f32, + pub vibrance: f32, + pub saturation: f32, + pub sharpening: f32, + pub noise: f32, + pub vignette: f32, +} + +impl RawSettings { + /// Replace non-finite values with neutral settings and constrain every + /// control to the range the editor exposes. PSD blocks are untrusted + /// input, and public callers need the same guarantee as the UI. + pub fn sanitized(self) -> RawSettings { + let signed = |value: f32| { + if value.is_finite() { + value.clamp(-100.0, 100.0) + } else { + 0.0 + } + }; + let unsigned = |value: f32, max: f32| { + if value.is_finite() { + value.clamp(0.0, max) + } else { + 0.0 + } + }; + RawSettings { + temperature: signed(self.temperature), + tint: signed(self.tint), + exposure: if self.exposure.is_finite() { + self.exposure.clamp(-5.0, 5.0) + } else { + 0.0 + }, + contrast: signed(self.contrast), + highlights: signed(self.highlights), + shadows: signed(self.shadows), + whites: signed(self.whites), + blacks: signed(self.blacks), + clarity: signed(self.clarity), + dehaze: signed(self.dehaze), + vibrance: signed(self.vibrance), + saturation: signed(self.saturation), + sharpening: unsigned(self.sharpening, 150.0), + noise: unsigned(self.noise, 100.0), + vignette: signed(self.vignette), + } + } +} + +/// The original capture behind a rendered RAW layer. +/// +/// `Arc` makes history snapshots and live previews cheap: all of them share +/// one immutable copy of a file that may be hundreds of megabytes. +#[derive(Debug, Clone)] +pub struct RawDevelopment { + pub source: Arc<[u8]>, + pub settings: RawSettings, +} + +impl PartialEq for RawDevelopment { + fn eq(&self, other: &Self) -> bool { + // Settings almost always differ during an edit, so compare them + // before considering a potentially enormous byte slice. History + // snapshots normally share the Arc and take the pointer-fast path. + self.settings == other.settings + && (Arc::ptr_eq(&self.source, &other.source) || self.source == other.source) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_are_finite_and_bounded_at_the_model_boundary() { + let settings = RawSettings { + temperature: f32::NAN, + exposure: 90.0, + contrast: -500.0, + sharpening: 500.0, + noise: -4.0, + ..RawSettings::default() + } + .sanitized(); + assert_eq!(settings.temperature, 0.0); + assert_eq!(settings.exposure, 5.0); + assert_eq!(settings.contrast, -100.0); + assert_eq!(settings.sharpening, 150.0); + assert_eq!(settings.noise, 0.0); + } +} diff --git a/docs/web.md b/docs/web.md index fc330790..caf264ad 100644 --- a/docs/web.md +++ b/docs/web.md @@ -116,7 +116,11 @@ rather than left to fail. Camera raws open through the pure-Rust `schist-codec-raw` decoder, the same code the desktop runs; there is no library to load and nothing is -refused on the web that the desktop would open. +refused on the web that the desktop would open. Camera Raw development is +the same too: the original capture and its settings stay with the layer and +round-trip through downloaded PSD/PSB files. Browser previews cannot leave +the main thread, so they use the fast demosaic path but can still pause the +interface longer than their desktop equivalents on a large capture. - Text layers can only use the fonts the page ships (IBM Plex Sans Regular today — add more in `web/fonts/` and they are picked up by the diff --git a/plugins/codecs-common/src/raw.rs b/plugins/codecs-common/src/raw.rs index 93705f20..11609319 100644 --- a/plugins/codecs-common/src/raw.rs +++ b/plugins/codecs-common/src/raw.rs @@ -17,8 +17,28 @@ //! histogram until 1% of the pixels clip. The result lands near the //! camera's own JPEG in brightness with the highlights still there. -use schist_core::Document; +use schist_core::{Document, RawDevelopment, RawSettings}; use schist_plugin_api::CodecPlugin; + +pub use schist_codec_raw::demosaic::Quality as RawQuality; + +/// A camera capture developed into straight-alpha, sRGB-encoded floats. +/// Kept flat so the app can apply its remaining Camera Raw controls before +/// turning the result into document tiles. +#[derive(Debug)] +pub struct DevelopedRaw { + pub width: usize, + pub height: usize, + pub rgba: Vec, +} + +/// File suffixes dispatched to the camera-raw codec. Shared with the app so +/// a direct capture open can start in the development dialog, while a PSD +/// containing a RAW-backed layer simply opens as a document. +pub const RAW_EXTENSIONS: &[&str] = &[ + "dng", "nef", "nrw", "arw", "srf", "sr2", "cr2", "cr3", "crw", "raf", "orf", "rw2", "rwl", + "pef", "srw", "erf", "kdc", "dcr", "mrw", "mos", "iiq", "3fr", "fff", "mef", "x3f", "raw", +]; /// Bring linear developed pixels up to display brightness and encode /// them as sRGB, in place. /// @@ -31,7 +51,7 @@ use schist_plugin_api::CodecPlugin; /// darkening, so an exposed-to-the-right frame is left alone — and /// above the knee an exponential shoulder (the one HDR captures get) /// that compresses the top towards white instead of cutting it off. -fn expose_and_encode(rgba: &mut [f32]) { +fn expose_and_encode_with(rgba: &mut [f32], exposure: f32) { const KNEE: f32 = 0.85; const MAX_GAIN: f32 = 4.0; const BINS: usize = 4096; @@ -55,11 +75,20 @@ fn expose_and_encode(rgba: &mut [f32]) { break; } } - let gain = if p99 > 0.0 { + let auto_gain = if p99 > 0.0 { (1.0 / p99).clamp(1.0, MAX_GAIN) } else { 1.0 }; + // Exposure belongs in scene-linear light, before the shoulder and the + // sRGB transfer curve. Non-finite public input is neutral rather than a + // way to turn the entire frame into NaNs. + let exposure = if exposure.is_finite() { + exposure.clamp(-5.0, 5.0) + } else { + 0.0 + }; + let gain = auto_gain * 2.0f32.powf(exposure); // The shoulder leaves everything in 0..=1, and the tiles hold 16 // bits, so the curve is a table over that range rather than a @@ -80,6 +109,11 @@ fn expose_and_encode(rgba: &mut [f32]) { } } +#[cfg(test)] +fn expose_and_encode(rgba: &mut [f32]) { + expose_and_encode_with(rgba, 0.0); +} + /// The sRGB transfer curve, linear light to signal. fn srgb_encode(v: f32) -> f32 { if v <= 0.003_130_8 { @@ -97,6 +131,20 @@ pub fn embedded_preview(bytes: &[u8]) -> anyhow::Result guarded(|| native::embedded_preview(bytes)) } +/// Re-develop an original capture with RAW-domain controls. +/// +/// Temperature and tint alter the camera's white-balance multipliers before +/// demosaic; exposure is applied in scene-linear light before the display +/// curve. The remaining [`RawSettings`] controls operate on the developed +/// image and are applied by the Camera Raw filter in the host. +pub fn develop_rgba( + bytes: &[u8], + settings: RawSettings, + quality: RawQuality, +) -> anyhow::Result { + guarded(|| native::develop_rgba(bytes, settings, quality)) +} + /// Camera raw files, import only. pub struct RawCodec; @@ -108,11 +156,7 @@ impl CodecPlugin for RawCodec { "Camera Raw" } fn extensions(&self) -> &'static [&'static str] { - &[ - "dng", "nef", "nrw", "arw", "srf", "sr2", "cr2", "cr3", "crw", "raf", "orf", "rw2", - "rwl", "pef", "srw", "erf", "kdc", "dcr", "mrw", "mos", "iiq", "3fr", "fff", "mef", - "x3f", "raw", - ] + RAW_EXTENSIONS } fn probe(&self, bytes: &[u8]) -> bool { // The native crate's probe knows every container, including @@ -145,11 +189,12 @@ fn guarded(f: impl FnOnce() -> anyhow::Result) -> anyhow::Result { /// The pure-Rust path. mod native { - use super::expose_and_encode; + use super::{expose_and_encode_with, DevelopedRaw, RawDevelopment, RawQuality, RawSettings}; use anyhow::Context as _; use schist_codec_raw::{DevelopOptions, Orientation}; use schist_color::Depth; use schist_core::Document; + use std::sync::Arc; /// Decode and develop through `schist-codec-raw`, then the /// exposure lift and encoding. A body the camera table has no @@ -157,6 +202,31 @@ mod native { /// recognisable, not right — and says so in the log; adding its /// matrix to the table is the fix. pub(super) fn develop_document(bytes: &[u8]) -> anyhow::Result { + let developed = develop_rgba(bytes, RawSettings::default(), RawQuality::Best)?; + let mut doc = crate::deep_document( + "Raw", + developed.width as u32, + developed.height as u32, + &developed.rgba, + Depth::Sixteen, + None, + ) + .context("assembling document")?; + if let Some(layer) = doc.tree.layers.first_mut() { + layer.raw = Some(Box::new(RawDevelopment { + source: Arc::from(bytes), + settings: RawSettings::default(), + })); + } + Ok(doc) + } + + pub(super) fn develop_rgba( + bytes: &[u8], + settings: RawSettings, + quality: RawQuality, + ) -> anyhow::Result { + let settings = settings.sanitized(); let raw = schist_codec_raw::decode(bytes).context("decoding")?; if raw.color_matrix.is_none() { log::warn!( @@ -165,16 +235,38 @@ mod native { raw.model ); } - let developed = - schist_codec_raw::develop(&raw, &DevelopOptions::default()).context("developing")?; - let (w, h) = (developed.width as u32, developed.height as u32); + let options = DevelopOptions { + quality, + white_balance: Some(adjusted_white_balance(raw.wb_coeffs, settings)), + ..DevelopOptions::default() + }; + let developed = schist_codec_raw::develop(&raw, &options).context("developing")?; let mut rgba = Vec::with_capacity(developed.rgb.len() / 3 * 4); for px in developed.rgb.as_chunks::<3>().0 { rgba.extend_from_slice(&[px[0], px[1], px[2], 1.0]); } - expose_and_encode(&mut rgba); - crate::deep_document("Raw", w, h, &rgba, Depth::Sixteen, None) - .context("assembling document") + expose_and_encode_with(&mut rgba, settings.exposure); + Ok(DevelopedRaw { + width: developed.width, + height: developed.height, + rgba, + }) + } + + fn adjusted_white_balance(mut wb: [f32; 4], settings: RawSettings) -> [f32; 4] { + let finite = |value: f32| if value.is_finite() { value } else { 0.0 }; + let temperature = finite(settings.temperature).clamp(-100.0, 100.0) / 100.0; + let tint = finite(settings.tint).clamp(-100.0, 100.0) / 100.0; + + // Work in stops so opposite slider directions are reciprocal. A + // warmer setting trades blue gain for red; positive tint trades + // green for equal red/blue (magenta). `develop` normalises green to + // one after validating all four coefficients. + wb[0] *= 2.0f32.powf(temperature * 0.5 + tint * 0.125); + wb[2] *= 2.0f32.powf(-temperature * 0.5 + tint * 0.125); + wb[1] *= 2.0f32.powf(-tint * 0.25); + wb[3] *= 2.0f32.powf(-tint * 0.25); + wb } /// The embedded JPEG, decoded and turned upright. @@ -379,6 +471,12 @@ pub(crate) mod tests { assert_eq!((doc.width, doc.height), (64, 32)); assert_eq!(doc.depth, schist_color::Depth::Sixteen, "raws keep 16 bits"); assert!(doc.icc_profile.is_none(), "developed to sRGB, no profile"); + let raw = doc.tree.layers[0] + .raw + .as_deref() + .expect("the original capture should stay attached"); + assert_eq!(raw.source.as_ref(), synthetic_dng(64, 32)); + assert_eq!(raw.settings, RawSettings::default()); let tiles = &doc.tree.layers[0].as_raster().unwrap().tiles; // Grey in, grey out: the as-shot balance is neutral and the // matrix is sRGB's own. @@ -405,6 +503,39 @@ pub(crate) mod tests { assert_eq!(tiles.pixel(12, 12).a, 1.0); } + #[test] + fn raw_controls_run_before_display_encoding() { + let bytes = synthetic_dng(64, 32); + let neutral = develop_rgba(&bytes, RawSettings::default(), RawQuality::Best).unwrap(); + let exposed = develop_rgba( + &bytes, + RawSettings { + exposure: 1.0, + ..RawSettings::default() + }, + RawQuality::Best, + ) + .unwrap(); + let warm = develop_rgba( + &bytes, + RawSettings { + temperature: 100.0, + ..RawSettings::default() + }, + RawQuality::Best, + ) + .unwrap(); + let at = (12 * 64 + 12) * 4; + assert!( + exposed.rgba[at + 1] > neutral.rgba[at + 1], + "positive EV should lift linear-light green before encoding" + ); + assert!( + warm.rgba[at] > neutral.rgba[at] && warm.rgba[at + 2] < neutral.rgba[at + 2], + "warmer white balance should trade blue for red" + ); + } + #[test] fn exposure_lifts_a_dark_frame_and_rolls_the_top_off() { // 99% of the frame at 0.2 linear, 1% at 0.9: the lift is the