LR-EXP-COLORSPACE: export in sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB or Rec. 2020

RenderRequest gains an output space (default sRGB; previews stay sRGB). The per-pixel stage converts
scene-linear Rec.2020 to the target primaries, gamut-maps into the target gamut and encodes with the
target curve, on CPU and GPU (WGSL finish kernel reads the matrix, luma weights and curve from its
parameter block). sRGB output is byte-identical to before on the CPU. Export embeds our own
generated ICC profile for the space; AVIF stays sRGB. ExportOptions.colorSpace, export dialog and
MCP export schema. Tests: P3 colour survives a P3 export (decoded through the embedded profile),
every space/format round-trips its profile, GPU equivalence per space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 12:49:13 -04:00
co-authored by Claude Opus 5.5
parent 08f2834216
commit 7f40a7a7d9
18 changed files with 500 additions and 57 deletions
Generated
+1
View File
@@ -2208,6 +2208,7 @@ dependencies = [
"proptest",
"rayon",
"serde",
"serde_json",
]
[[package]]
+2
View File
@@ -88,6 +88,8 @@ impl WireJob {
quality: if self.draft { Quality::Draft } else { Quality::Full },
apply_crop: self.apply_crop,
overlay: lightcraft_engine::pipeline::Overlay::from_parts(self.overlay.0, self.overlay.1),
// workers render previews (exports run in-process)
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
}
}
+1 -1
View File
@@ -118,7 +118,7 @@ pub const PROPHOTO: RgbSpace =
RgbSpace { name: "ProPhoto RGB", r: Xy::new(0.734699, 0.265301), g: Xy::new(0.159597, 0.840403), b: Xy::new(0.036598, 0.000105), white: D50 };
/// The 1998 "Adobe RGB" primaries (a public colour-space definition; name used descriptively).
pub const ADOBE_RGB: RgbSpace =
RgbSpace { name: "Adobe RGB (1998)", r: Xy::new(0.64, 0.33), g: Xy::new(0.21, 0.71), b: Xy::new(0.15, 0.06), white: D65 };
RgbSpace { name: "Adobe RGB (1998) compatible", r: Xy::new(0.64, 0.33), g: Xy::new(0.21, 0.71), b: Xy::new(0.15, 0.06), white: D65 };
/// The develop pipeline's working space: linear Rec.2020 / D65.
pub const WORKING: RgbSpace = REC2020;
+123 -7
View File
@@ -1,9 +1,11 @@
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF with an embedded sRGB profile,
//! optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF in sRGB, Display P3, Adobe RGB
//! (1998) compatible, ProPhoto RGB or Rec. 2020 with an embedded ICC profile we generate from the
//! published primaries and curves, optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop
//! app, CLI, MCP and the web build share it; writing the file is the caller's job.
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, TiffCompression, encode, icc};
use lightcraft_meta::{DateTime, Gps, Metadata};
pub use lightcraft_pipeline::OutputSpace;
use lightcraft_raster::Rgba8;
use serde::{Deserialize, Serialize};
@@ -198,6 +200,8 @@ pub struct ExportOptions {
pub remove_location: bool,
/// Text watermark (none when absent or the text is empty).
pub watermark: Option<Watermark>,
/// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile).
pub color_space: OutputSpace,
}
impl Default for ExportOptions {
@@ -213,12 +217,14 @@ impl Default for ExportOptions {
metadata: MetadataPolicy::All,
remove_location: false,
watermark: None,
color_space: OutputSpace::Srgb,
}
}
}
impl ExportOptions {
/// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, `sharpenAmount`, `naming`).
/// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`,
/// `sharpenAmount`, `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`).
pub fn from_json(p: &serde_json::Value) -> Self {
use serde_json::Value;
let d = Self::default();
@@ -243,9 +249,15 @@ impl ExportOptions {
_ => None,
}
.filter(|w: &Watermark| !w.text.trim().is_empty()),
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
}
}
/// The colour space the file is actually written in (AVIF: sRGB).
pub fn effective_space(&self) -> OutputSpace {
if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space }
}
/// Output file name for photo `stem` at 1-based position `seq` in a batch.
pub fn file_name(&self, stem: &str, seq: usize) -> String {
let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() };
@@ -304,7 +316,8 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount
}
}
/// Encode a rendered (display-referred sRGB) image according to `o`. Resizing to `long_edge` is the
/// Encode a rendered display-referred image according to `o` (its pixels must already be in
/// `o.effective_space()`). Resizing to `long_edge` is the
/// caller's job (render at that size); sharpening is applied here.
pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result<Vec<u8>, String> {
encode_with_metadata(img, o, None)
@@ -314,10 +327,12 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result<Vec<u8>, String> {
pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
let mut img = img.clone();
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
let space = o.effective_space();
if let Some(wm) = &o.watermark {
draw_watermark(&mut img, wm);
let wm = Watermark { color: srgb8_in(space, wm.color), ..wm.clone() };
draw_watermark(&mut img, &wm);
}
let profile = icc::write_named(NamedSpace::Srgb);
let profile = icc::write_named(named_space(space));
let exif = meta.map(lightcraft_meta::write_exif);
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() };
@@ -358,6 +373,31 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
r.map_err(|e| e.to_string())
}
/// The codecs' name of an output space (for its ICC profile).
pub fn named_space(s: OutputSpace) -> NamedSpace {
match s {
OutputSpace::Srgb => NamedSpace::Srgb,
OutputSpace::DisplayP3 => NamedSpace::DisplayP3,
OutputSpace::AdobeRgb => NamedSpace::AdobeRgb,
OutputSpace::ProPhoto => NamedSpace::ProPhoto,
OutputSpace::Rec2020 => NamedSpace::Rec2020,
}
}
/// An 8-bit sRGB colour expressed in `space` (encoded with its curve).
pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] {
if space == OutputSpace::Srgb {
return c;
}
let lin = c.map(lightcraft_color::transfer::decode_srgb8);
let m = lightcraft_color::SRGB.to_space(&space.rgb_space()).to_f32();
let t = space.trc();
std::array::from_fn(|i| {
let v = m[i][0] * lin[0] + m[i][1] * lin[1] + m[i][2] * lin[2];
(t.encode(v) * 255.0 + 0.5) as u8
})
}
/// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds.
fn parse_shutter(s: &str) -> Option<f64> {
let s = s.trim().trim_end_matches(['s', '"']).trim();
@@ -414,7 +454,7 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI
let full = p.width.max(p.height).max(1) as usize;
let size = o.long_edge.map_or(full, |l| l as usize);
let meta = export_metadata(p, o);
let r = session.render_now(id, size, size)?;
let r = session.render_export(id, size, o.effective_space())?;
let bytes = encode_with_metadata(&r.image, o, meta.as_ref())?;
Ok(Exported { file_name: o.file_name(&stem, seq), bytes, width: r.image.width, height: r.image.height })
}
@@ -516,4 +556,80 @@ mod tests {
assert_eq!(o.long_edge, Some(2048));
assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg");
}
/// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`.
fn p3_green(space: OutputSpace) -> Rgba8 {
use lightcraft_color::{DISPLAY_P3, REC2020};
let c = DISPLAY_P3.to_space(&REC2020).apply_f32([0.04, 0.45, 0.04]);
let src = lightcraft_raster::Rgb32f::filled(16, 16, c);
let req = lightcraft_pipeline::RenderRequest { space, ..lightcraft_pipeline::RenderRequest::fit(16, 16) };
lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req).image
}
/// Decode `bytes` and return the centre pixel in linear sRGB primaries (unclamped), plus the
/// recognised space of the embedded profile.
fn decoded_in_srgb(bytes: &[u8]) -> ([f32; 3], Option<NamedSpace>) {
let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes");
let m = d.space.to_space(&lightcraft_color::SRGB);
(m.apply_f32(d.image.get(8, 8)), d.space.named)
}
#[test]
fn p3_colour_survives_a_p3_export_and_is_clipped_in_srgb() {
let o = |space| ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() };
let (p3, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::DisplayP3), &o(OutputSpace::DisplayP3)).unwrap());
assert_eq!(named, Some(NamedSpace::DisplayP3));
assert!(p3[0] < -0.03 || p3[2] < -0.03, "outside sRGB after a P3 round trip: {p3:?}");
let (s, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::Srgb), &o(OutputSpace::Srgb)).unwrap());
assert_eq!(named, Some(NamedSpace::Srgb));
assert!(s.iter().all(|v| *v > -0.002), "{s:?}");
// the green itself is about the same brightness either way
assert!((p3[1] - s[1]).abs() < 0.15, "{p3:?} {s:?}");
}
#[test]
fn every_space_embeds_its_own_profile_and_round_trips() {
let grey = {
let src = lightcraft_raster::Rgb32f::filled(16, 16, [0.18; 3]);
let base = lightcraft_pipeline::RenderRequest::fit(16, 16);
move |space| {
lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &lightcraft_pipeline::RenderRequest { space, ..base })
.image
}
};
let reference =
decoded_in_srgb(&encode_image(&grey(OutputSpace::Srgb), &ExportOptions { format: ExportFormat::Tiff, ..Default::default() }).unwrap()).0;
for space in OutputSpace::ALL {
for format in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
let o = ExportOptions { format, color_space: space, ..Default::default() };
let bytes = encode_image(&grey(space), &o).unwrap();
let (px, named) = decoded_in_srgb(&bytes);
assert_eq!(named, Some(named_space(space)), "{space:?} {format:?}");
// a neutral grey decodes to the same linear value whatever the output space
for c in 0..3 {
assert!((px[c] - reference[c]).abs() < 0.01, "{space:?} {format:?}: {px:?} vs {reference:?}");
}
}
let icc = icc::write_named(named_space(space));
let info = icc::parse(&icc).expect("our profile parses");
assert_eq!(info.named, Some(named_space(space)));
assert!(info.description.as_deref().is_some_and(|d| !d.is_empty()));
}
let o = ExportOptions { format: ExportFormat::Avif, color_space: OutputSpace::ProPhoto, ..Default::default() };
assert_eq!(o.effective_space(), OutputSpace::Srgb);
let o = ExportOptions::from_json(&serde_json::json!({"colorSpace": "displayP3"}));
assert_eq!(o.color_space, OutputSpace::DisplayP3);
assert_eq!(ExportOptions::from_json(&serde_json::json!({"colorSpace": "Adobe RGB (1998) compatible"})).color_space, OutputSpace::AdobeRgb);
}
#[test]
fn srgb_watermark_colour_is_converted() {
assert_eq!(srgb8_in(OutputSpace::Srgb, [10, 200, 30]), [10, 200, 30]);
for s in OutputSpace::ALL {
assert_eq!(srgb8_in(s, [255, 255, 255]), [255, 255, 255], "{s:?}");
assert_eq!(srgb8_in(s, [0, 0, 0]), [0, 0, 0], "{s:?}");
}
let p3 = srgb8_in(OutputSpace::DisplayP3, [255, 0, 0]);
assert!(p3[0] < 255 && p3[1] > 0, "{p3:?}");
}
}
+10
View File
@@ -518,6 +518,16 @@ impl crate::Session {
r.rendered
}
/// Synchronous render for export: like [`Self::render_now`], into colour space `space`.
pub fn render_export(&mut self, id: PhotoId, max: usize, space: lightcraft_pipeline::OutputSpace) -> Result<Rendered, String> {
let mut job = self.render_job(id, max, max, false, true).ok_or("no such photo")?;
job.request.space = space;
job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f);
let r = job.run();
self.accept(&r);
r.rendered
}
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
+1 -2
View File
@@ -125,8 +125,7 @@ fn constants() -> String {
use lightcraft_pipeline::tone::{LUT_MAX_EV, LUT_MIN_EV, LUT_N};
let mut s = String::new();
let [to_lms, from_lms, to_lab, from_lab] = lightcraft_color::perceptual::oklab_matrices();
let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32();
for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab), ("TO_SRGB", to_srgb)] {
for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab)] {
let rows: Vec<String> = m.iter().map(|r| format!("vec3<f32>({:?}, {:?}, {:?})", r[0], r[1], r[2])).collect();
s += &format!("const {name} = array<vec3<f32>, 3>({});\n", rows.join(", "));
}
+9
View File
@@ -66,6 +66,10 @@ const FIELDS: &[(&str, usize)] = &[
("CALIB", 1),
("CALIB_M", 9),
("SHADOW_TINT", 1),
("OUT_M", 9),
("OUT_Y", 3),
("OUT_TRC", 1),
("OUT_GAMMA", 1),
];
/// `(name, index)` of every field (for the WGSL constants).
@@ -212,6 +216,11 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
p.fs("CALIB_M", m.as_flattened());
}
p.f("SHADOW_TINT", fp.shadow_tint);
p.fs("OUT_M", fp.to_out.as_flattened());
p.fs("OUT_Y", &fp.out_luma);
let (trc, gamma) = fp.out_trc.code();
p.u("OUT_TRC", trc);
p.f("OUT_GAMMA", gamma);
if let Some((amt, cell, rough, seed)) = fp.grain {
p.b("GRAIN", true);
p.f("GRAIN_AMT", amt);
+1 -1
View File
@@ -460,7 +460,7 @@ pub fn render(
lap("masks", &mut t, &mut cx);
// 5. per-pixel stage
let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air);
let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space);
let present = Present { clarity: prep.clarity.is_some(), texture: prep.texture.is_some(), dark: prep.dark.is_some() };
let (p, aux) = finish_block(&fp, &terms, &present);
let aux = gpu.upload(&aux);
+29 -3
View File
@@ -218,6 +218,26 @@ fn enc8(v: f32) -> u32 {
return u32(clamp(v, 0.0, 1.0) * 255.0 + 0.5);
}
// sRGB-curve-encoded value → the output space's own curve (`OutputTrc`: 0 sRGB, 1 gamma, 2 Rec.709).
fn out_encode(v: f32) -> f32 {
let kind = pu(F_OUT_TRC);
if (kind == 0u) {
return v;
}
let e = clamp(v, 0.0, 1.0);
var l = e / 12.92;
if (e > 0.04045) {
l = pow((e + 0.055) / 1.055, 2.4);
}
if (kind == 1u) {
return pow(l, 1.0 / pf(F_OUT_GAMMA));
}
if (l < 0.018) {
return l * 4.5;
}
return 1.099 * pow(l, 0.45) - 0.099;
}
@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let w = pu(F_W);
@@ -399,9 +419,14 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
}
}
// --- gamut map to sRGB (desaturate towards luminance until in range)
var r = mul3(TO_SRGB, d);
let yy = clamp(0.2126 * r.x + 0.7152 * r.y + 0.0722 * r.z, 0.0, 1.0);
// --- gamut map to the output space (desaturate towards luminance until in range)
let om = array<vec3<f32>, 3>(
vec3<f32>(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)),
vec3<f32>(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)),
vec3<f32>(pf(F_OUT_M + 6u), pf(F_OUT_M + 7u), pf(F_OUT_M + 8u)),
);
var r = mul3(om, d);
let yy = clamp(pf(F_OUT_Y) * r.x + pf(F_OUT_Y + 1u) * r.y + pf(F_OUT_Y + 2u) * r.z, 0.0, 1.0);
var tg = 1.0;
for (var k = 0u; k < 3u; k++) {
let cc = r[k];
@@ -439,5 +464,6 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum));
e = e + k;
}
e = vec3<f32>(out_encode(e.x), out_encode(e.y), out_encode(e.z));
out[i] = enc8(e.x) | (enc8(e.y) << 8u) | (enc8(e.z) << 16u) | (255u << 24u);
}
+29
View File
@@ -462,3 +462,32 @@ fn red_eye_matches() {
};
check("red eye + spot (cpu stage)", &src, &info, &s, &RenderRequest::fit(600, 600));
}
fn saturated(s: &mut DevelopSettings) {
typical(s);
s.color.saturation = 70.0;
s.color.vibrance = 40.0;
s.curve.highlights = 30.0;
s.curve.shadows = -20.0;
s.grain.amount = 30.0;
}
#[test]
fn output_spaces_match() {
if !gpu() {
return;
}
// Export colour spaces: matrix to the target primaries, gamut mapping into the target gamut and
// its encoding curve, on both renderers. Saturated edits push colours to the gamut edges.
use lightcraft_pipeline::OutputSpace;
let src = scene(0, 900, 600);
let raw = SourceInfo { raw: true, ..Default::default() };
for space in OutputSpace::ALL {
for (name, edit) in [("typical", typical as Edit), ("saturated + curves + grain", saturated as Edit)] {
let mut s = DevelopSettings::default();
edit(&mut s);
let req = RenderRequest { space, ..RenderRequest::fit(640, 640) };
check(&format!("{space:?} {name}"), &src, &raw, &s, &req);
}
}
}
+2
View File
@@ -188,6 +188,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"naming": {"type": "string", "description": "e.g. {name}-{seq}"},
"metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]},
"removeLocation": {"type": "boolean"},
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
"watermark": {"description": "Text, or {text, size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"}
}),
&[],
@@ -534,6 +535,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
"metadata",
"removeLocation",
"watermark",
"colorSpace",
],
),
)
+1
View File
@@ -20,3 +20,4 @@ serde = { workspace = true }
lightcraft-scenes = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }
serde_json = { workspace = true }
+66 -14
View File
@@ -1,8 +1,8 @@
//! The per-pixel stage: everything after the spatial planes are ready, in one parallel pass.
use lightcraft_color::luminance_2020;
use lightcraft_color::spline::{Lut1, MonotoneCurve};
use lightcraft_color::transfer::linear_to_srgb;
use lightcraft_color::{REC2020, SRGB, luminance_2020};
use lightcraft_develop::{DevelopSettings, LocalAdjustments, ToneCurve, VignetteStyle};
use lightcraft_geom::Point;
use lightcraft_raster::Rgba8;
@@ -10,6 +10,7 @@ use lightcraft_raster::Rgba8;
use crate::colorops::ColorOps;
use crate::geometry::Frame;
use crate::local::log_lum;
use crate::output::{OutputSpace, OutputTrc};
use crate::tone::ToneMap;
use crate::{Prepared, SourceInfo, for_rows};
@@ -153,7 +154,11 @@ pub struct FinishParams {
/// Refine Saturation as 0..1 (1 = the curves' own saturation).
pub refine_sat: f32,
pub vig: Option<Vig>,
pub to_srgb: [[f32; 3]; 3],
/// Linear Rec.2020 → linear output RGB, the output's luminance weights (gamut mapping) and its
/// encoding curve (see [`crate::output`]).
pub to_out: [[f32; 3]; 3],
pub out_luma: [f32; 3],
pub out_trc: OutputTrc,
pub hl: f32,
pub sh: f32,
pub clar: f32,
@@ -178,8 +183,18 @@ pub struct FinishParams {
}
impl FinishParams {
/// Parameters for a `w × h` render; `ev`/`air_pre` as in [`crate::Prepared`].
pub fn new(s: &DevelopSettings, frame: &Frame, info: &SourceInfo, w: usize, h: usize, px_per_long: f64, air_pre: f32) -> FinishParams {
/// Parameters for a `w × h` render into `space`; `ev`/`air_pre` as in [`crate::Prepared`].
#[allow(clippy::too_many_arguments)]
pub fn new(
s: &DevelopSettings,
frame: &Frame,
info: &SourceInfo,
w: usize,
h: usize,
px_per_long: f64,
air_pre: f32,
space: OutputSpace,
) -> FinishParams {
let effects = s.section_enabled("effects");
let (clar, tex, dehaze) = if effects {
((s.effects.clarity / 100.0) as f32, (s.effects.texture / 100.0) as f32, (s.effects.dehaze / 100.0) as f32)
@@ -205,7 +220,9 @@ impl FinishParams {
curves: curve_luts(&s.curve),
refine_sat: (s.curve.refine_saturation / 100.0).clamp(0.0, 1.0) as f32,
vig: if effects { vignette(s) } else { None },
to_srgb: REC2020.to_space(&SRGB).to_f32(),
to_out: space.from_working(),
out_luma: space.luma(),
out_trc: space.trc(),
hl: (s.light.highlights / 100.0) as f32,
sh: (s.light.shadows / 100.0) as f32,
clar,
@@ -228,10 +245,45 @@ impl FinishParams {
}
}
pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo) -> Rgba8 {
pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace) -> Rgba8 {
let (w, h) = (p.img.width, p.img.height);
let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air);
let FinishParams { tone, ops, curves, vig, to_srgb, hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev, grain, .. } = &fp;
let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
let trc = fp.out_trc;
let data = finish_with(p, &fp, |e| match trc {
OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255],
t => {
let x = e.map(|v| enc(t.encode(lightcraft_color::transfer::srgb_to_linear(v.clamp(0.0, 1.0)))));
[x[0], x[1], x[2], 255]
}
});
Rgba8 { width: w, height: h, data }
}
/// The per-pixel stage: every output pixel's colour in the output primaries, encoded with the sRGB
/// curve (tone curves and grain applied; not clamped), handed to `store` for the final encoding.
pub(crate) fn finish_with<T: Copy + Default + Send>(p: &Prepared, fp: &FinishParams, store: impl Fn([f32; 3]) -> T + Sync + Send) -> Vec<T> {
let (w, h) = (p.img.width, p.img.height);
let FinishParams {
tone,
ops,
curves,
vig,
to_out,
out_luma,
hl,
sh,
clar,
tex,
dehaze,
sharpen,
sharpen_mask,
air,
air_pre,
gain,
ev,
grain,
..
} = fp;
let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) =
(*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev);
let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect();
@@ -240,8 +292,8 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
let aspect = w as f32 / h as f32;
let srgb = srgb_lut();
let mut out = Rgba8::new(w, h);
for_rows(&mut out.data, w, |y, row| {
let mut out = vec![T::default(); w * h];
for_rows(&mut out, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
let i = y * w + x;
let raw = p.img.data[i];
@@ -388,14 +440,14 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
}
}
// --- gamut map to sRGB (desaturate towards luminance until in range)
let m = to_srgb;
// --- gamut map to the output space (desaturate towards luminance until in range)
let m = to_out;
let mut r = [
m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2],
m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2],
m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2],
];
let yy = (0.2126 * r[0] + 0.7152 * r[1] + 0.0722 * r[2]).clamp(0.0, 1.0);
let yy = (out_luma[0] * r[0] + out_luma[1] * r[1] + out_luma[2] * r[2]).clamp(0.0, 1.0);
let mut t = 1.0f32;
for c in r {
if c < 0.0 {
@@ -427,7 +479,7 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum));
e = e.map(|v| v + k);
}
*px = [enc(e[0]), enc(e[1]), enc(e[2]), 255];
*px = store(e);
}
});
out
+7 -3
View File
@@ -11,7 +11,7 @@
//! clarity, local adjustments (masks)
//! 3. tone map — contrast / whites / blacks filmic curve on luminance, highlight desaturation
//! 4. colour — vibrance, saturation, colour mixer, colour grading, B&W (OkLCh)
//! 5. display — gamut map to sRGB, encode, tone curves (parametric + point), vignette, grain
//! 5. display — gamut map to the output space (sRGB unless [`RenderRequest::space`] says otherwise), encode, tone curves (parametric + point), vignette, grain
//!
//! Spatial parameters are specified relative to the image's long edge, so a 400 px preview and a
//! 60 MP export look alike.
@@ -29,6 +29,7 @@ pub mod geometry;
pub mod local;
pub mod masks;
pub mod optics;
pub mod output;
pub mod profiles;
pub mod redeye;
pub mod spots;
@@ -37,6 +38,7 @@ pub mod transform;
pub mod upright;
pub mod visualize;
pub use output::{OutputSpace, OutputTrc};
pub use visualize::Overlay;
use lightcraft_develop::{DevelopSettings, Treatment};
@@ -84,11 +86,13 @@ pub struct RenderRequest {
pub apply_crop: bool,
/// A diagnostic view drawn over the result (e.g. Point Color's "visualize range").
pub overlay: Overlay,
/// Colour space of the result (exports; previews stay sRGB).
pub space: OutputSpace,
}
impl RenderRequest {
pub fn fit(max_w: usize, max_h: usize) -> Self {
Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None }
Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None, space: OutputSpace::Srgb }
}
}
@@ -344,7 +348,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
if let Some((a, c)) = shared {
c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes });
}
let image = finish::finish(&prep, s, frame, info);
let image = finish::finish(&prep, s, frame, info, req.space);
lap("finish", &mut t);
let histogram = Histogram::of_srgb8(&image);
lap("histogram", &mut t);
+175
View File
@@ -0,0 +1,175 @@
//! Output encoding: the colour space (primaries + transfer curve) and sample format a render is
//! delivered in. Previews are always 8-bit sRGB; exports may ask for a wide-gamut space and/or
//! 16-bit or linear float samples.
//!
//! The per-pixel stage works in scene-linear Rec.2020 and ends by converting to the target's
//! primaries, gamut mapping into the *target* gamut, and encoding. Tone curves and grain operate on
//! sRGB-curve-encoded values of the target primaries (identical to the sRGB path when the target is
//! sRGB); afterwards the values are re-encoded with the target's own curve.
use lightcraft_color::{ADOBE_RGB, DISPLAY_P3, PROPHOTO, REC2020, RgbSpace, SRGB};
use serde::{Deserialize, Serialize};
/// The RGB space an image is rendered into.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum OutputSpace {
#[default]
Srgb,
DisplayP3,
/// Primaries and gamma of the published Adobe RGB (1998) specification ("compatible": our own
/// profile, built from the published numbers).
AdobeRgb,
ProPhoto,
Rec2020,
}
/// The encoding curve of an [`OutputSpace`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum OutputTrc {
/// IEC 61966-2-1 (sRGB, Display P3).
Srgb,
/// Pure power law: encoded = linear^(1/g).
Gamma(f32),
/// ITU-R BT.709 / BT.2020 OETF.
Rec709,
}
impl OutputTrc {
/// Linear → encoded (input clamped to 0..1).
pub fn encode(self, v: f32) -> f32 {
let v = v.clamp(0.0, 1.0);
match self {
OutputTrc::Srgb => lightcraft_color::transfer::linear_to_srgb(v),
OutputTrc::Gamma(g) => v.powf(1.0 / g),
OutputTrc::Rec709 => {
if v < 0.018 {
v * 4.5
} else {
1.099 * v.powf(0.45) - 0.099
}
}
}
}
/// Encoded → linear (input clamped to 0..1).
pub fn decode(self, e: f32) -> f32 {
let e = e.clamp(0.0, 1.0);
match self {
OutputTrc::Srgb => lightcraft_color::transfer::srgb_to_linear(e),
OutputTrc::Gamma(g) => e.powf(g),
OutputTrc::Rec709 => {
if e < 0.081 {
e / 4.5
} else {
((e + 0.099) / 1.099).powf(1.0 / 0.45)
}
}
}
}
/// Code for the GPU kernel: 0 sRGB, 1 gamma, 2 Rec.709.
pub fn code(self) -> (u32, f32) {
match self {
OutputTrc::Srgb => (0, 1.0),
OutputTrc::Gamma(g) => (1, g),
OutputTrc::Rec709 => (2, 1.0),
}
}
}
impl OutputSpace {
pub const ALL: [OutputSpace; 5] = [OutputSpace::Srgb, OutputSpace::DisplayP3, OutputSpace::AdobeRgb, OutputSpace::ProPhoto, OutputSpace::Rec2020];
pub fn rgb_space(self) -> RgbSpace {
match self {
OutputSpace::Srgb => SRGB,
OutputSpace::DisplayP3 => DISPLAY_P3,
OutputSpace::AdobeRgb => ADOBE_RGB,
OutputSpace::ProPhoto => PROPHOTO,
OutputSpace::Rec2020 => REC2020,
}
}
pub fn trc(self) -> OutputTrc {
match self {
OutputSpace::Srgb | OutputSpace::DisplayP3 => OutputTrc::Srgb,
OutputSpace::AdobeRgb => OutputTrc::Gamma(563.0 / 256.0),
OutputSpace::ProPhoto => OutputTrc::Gamma(1.8),
OutputSpace::Rec2020 => OutputTrc::Rec709,
}
}
/// User-facing name.
pub fn label(self) -> &'static str {
match self {
OutputSpace::Srgb => "sRGB",
OutputSpace::DisplayP3 => "Display P3",
OutputSpace::AdobeRgb => "Adobe RGB (1998) compatible",
OutputSpace::ProPhoto => "ProPhoto RGB",
OutputSpace::Rec2020 => "Rec. 2020",
}
}
pub fn parse(s: &str) -> Option<OutputSpace> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
Some(match k.as_str() {
"srgb" => OutputSpace::Srgb,
"displayp3" | "p3" => OutputSpace::DisplayP3,
"adobergb" | "adobergb1998" | "adobergb1998compatible" | "widegamutrgb" => OutputSpace::AdobeRgb,
"prophoto" | "prophotorgb" | "rommrgb" => OutputSpace::ProPhoto,
"rec2020" | "bt2020" | "itur2020" => OutputSpace::Rec2020,
_ => return None,
})
}
/// Linear Rec.2020 → linear target RGB (Bradford-adapted for D50 targets), row-major.
pub fn from_working(self) -> [[f32; 3]; 3] {
REC2020.to_space(&self.rgb_space()).to_f32()
}
/// Luminance weights of the target's linear RGB, as the gamut mapper uses them. sRGB keeps the
/// rounded BT.709 weights the pipeline has always used (byte-identical previews and exports).
pub fn luma(self) -> [f32; 3] {
match self {
OutputSpace::Srgb => [0.2126, 0.7152, 0.0722],
o => o.rgb_space().luma().map(|v| v as f32),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trcs_invert() {
for o in OutputSpace::ALL {
let t = o.trc();
for i in 0..=100 {
let x = i as f32 / 100.0;
assert!((t.decode(t.encode(x)) - x).abs() < 1e-5, "{o:?} {x}");
}
}
}
#[test]
fn parse_and_serde_names() {
for o in OutputSpace::ALL {
let s = serde_json::to_value(o).unwrap();
assert_eq!(OutputSpace::parse(s.as_str().unwrap()), Some(o));
assert_eq!(OutputSpace::parse(o.label()), Some(o), "{}", o.label());
}
}
#[test]
fn white_maps_to_white() {
for o in OutputSpace::ALL {
let m = o.from_working();
for row in m {
assert!((row.iter().sum::<f32>() - 1.0).abs() < 1e-4, "{o:?}");
}
assert!((o.luma().iter().sum::<f32>() - 1.0).abs() < 1e-4);
}
}
}
+19
View File
@@ -109,6 +109,24 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
&[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF")],
&mut opts.format,
);
if opts.format == F::Avif {
ui.label(egui::RichText::new("Color space: sRGB (AVIF)").color(t.text_dim));
} else {
use lightcraft_engine::export::OutputSpace as C;
choices(
ui,
"Color space",
"exportColorSpace",
&[
(C::Srgb, "sRGB"),
(C::DisplayP3, "P3"),
(C::AdobeRgb, "Adobe RGB"),
(C::ProPhoto, "ProPhoto"),
(C::Rec2020, "Rec.2020"),
],
&mut opts.color_space,
);
}
if matches!(opts.format, F::Jpeg | F::Avif) {
let mut q = opts.quality as f64;
if num(ui, &QUALITY, &mut q) {
@@ -292,6 +310,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
"format": opts.format, "quality": opts.quality, "longEdge": long_edge, "limitKb": limit_kb,
"sharpen": opts.sharpen, "sharpenAmount": opts.sharpen_amount, "naming": opts.naming, "dir": dir,
"metadata": opts.metadata, "removeLocation": opts.remove_location, "watermark": opts.watermark,
"colorSpace": opts.color_space,
}),
),
Dialog::Merge { opts } => crate::merge::start_final(app, opts),
+1 -1
View File
@@ -85,7 +85,7 @@ During development you can also point the client at `cargo run --release -p ligh
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle |
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
| `export {path, id?, longEdge?, quality?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) |
| `export {path, id?, longEdge?, quality?, colorSpace?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) |
Tools taking `id` make that photo active first; without it they act on the active photo.
+23 -25
View File
@@ -55,41 +55,39 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
Ordered by tier, then user value, then (low) effort. Take the first one nobody is working on.
1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible /
Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort.
2. **LR-EXP-PREVIOUS + LR-EXP-DIALOG presets** (P0) — remember the last export settings (⌘E "export with previous")
1. **LR-EXP-PREVIOUS + LR-EXP-DIALOG presets** (P0) — remember the last export settings (⌘E "export with previous")
and offer built-in export presets (small JPEG, full JPEG, TIFF). Medium value, low effort.
3. **LR-LIB-SHOWFINDER** (P0) — reveal the original in Finder/Explorer/file manager. Medium value, low effort.
4. **LR-EDIT-EFFECTS-VIGNETTE** (P0) — the vignette style (highlight priority / colour priority / paint overlay) is
2. **LR-LIB-SHOWFINDER** (P0) — reveal the original in Finder/Explorer/file manager. Medium value, low effort.
3. **LR-EDIT-EFFECTS-VIGNETTE** (P0) — the vignette style (highlight priority / colour priority / paint overlay) is
rendered but has no UI. Low effort.
5. **LR-VIEW-BEFOREAFTER** (P0) — add top/bottom and split top/bottom layouts (⌥Y). Low effort.
6. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask
4. **LR-VIEW-BEFOREAFTER** (P0) — add top/bottom and split top/bottom layouts (⌥Y). Low effort.
5. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask
kinds, colour/opacity choice), not just brush dabs and outlines. High value, medium effort.
7. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local
6. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local
Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort.
8. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫,
7. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫,
feather/opacity sliders, `[`/`]` size keys. Medium effort.
9. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort.
10. **LR-CROP-STRAIGHTEN** (P0) — draw-along-the-horizon straighten tool (+ double-click auto). Low effort.
11. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset;
8. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort.
9. **LR-CROP-STRAIGHTEN** (P0) — draw-along-the-horizon straighten tool (+ double-click auto). Low effort.
10. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset;
live preview while hovering presets/profiles/versions. Medium effort.
12. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER).
13. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing.
14. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific),
11. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER).
12. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing.
13. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific),
XMP prefs, cache, GPU. Medium effort.
15. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination
14. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination
album; browse folders before adding. Medium effort.
16. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in
15. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in
`crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi.
17. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no
16. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no
permissive pure-Rust decoder yet). High value, high effort (clean sources needed).
18. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort.
19. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names.
20. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render).
21. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) —
17. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort.
18. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names.
19. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render).
20. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) —
missing develop controls (calibration matters for XMP interop).
22. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice.
23. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
21. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice.
22. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
## Shortcuts: conflicts and missing bindings
@@ -444,7 +442,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | |
| LR-EXP-BITDEPTH | Bit depth | P1 | ⬜ | | 8-bit only |
| LR-EXP-COMPRESSION | TIFF compression | P1 | ⬜ | | always Deflate |
| LR-EXP-COLORSPACE | Output colour space | P0 | ⬜ | `crates/engine/src/export.rs` | sRGB only |
| LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC) |
| LR-EXP-HDR | HDR output | P2 | ⬜ | | |
| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | |
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | |