Develop a JPEG through the same pipeline as a RAW

DemosaicedImage gains a second producer, from_rgba8, alongside the CFA path.
Nothing about the type is CFA-specific — it is "an image on the GPU, ready to
adjust" — which is what lets develop mode work on a JPEG without the edit
graph or any operation knowing the source was not a RAW file.

The one real difference is the transfer function: sensor data is linear, a
JPEG is gamma-encoded. Every operation assumes linear scene-referred colour
(exposure is a multiply, and doubling a gamma-encoded value is not a stop), so
the shader prologue linearises once, at the only point where the two source
kinds still differ. The flag rides in as_shot_wb.w, which was padding. For a
JPEG the white balance uniform is neutral and the colour matrix is identity,
so both stay unconditional multiplies rather than becoming branches.

max_dimension is exposed because it is a hardware limit the caller must plan
around, not a failure to report afterwards: a 13728x8928 film scan exceeds the
common 8192 texture limit, and fitting it first is the only way to develop it
at all.

Assisted-by: LLM
This commit is contained in:
2026-08-09 21:07:20 +02:00
parent 050dcff5bb
commit 5786977a51
4 changed files with 566 additions and 6 deletions
+28
View File
@@ -190,6 +190,9 @@ pub fn compose_with_framing(ops: &[Box<dyn Operation>], framing: &Framing) -> Co
\x20 cam_to_srgb_1: vec4<f32>,\n\
\x20 cam_to_srgb_2: vec4<f32>,\n\
\x20 // As-shot white balance, the neutral point for the WB control.\n\
\x20 // `.w` is not padding: it flags a non-linear source (1.0 for a\n\
\x20 // gamma-encoded JPEG, 0.0 for demosaiced sensor data), which the\n\
\x20 // prologue reads to decide whether to linearise.\n\
\x20 as_shot_wb: vec4<f32>,\n",
);
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
@@ -293,6 +296,19 @@ fn encode_srgb(c: vec3<f32>) -> vec3<f32> {{
return select(hi, lo, c <= vec3<f32>(0.0031308));
}}
// The inverse, for sources that arrive already display-encoded.
//
// A JPEG is uploaded with its bytes untouched, so its values are gamma-encoded
// where the demosaicer's are linear. Every operation below assumes linear
// scene-referred colour — exposure is a multiply, and doubling a gamma-encoded
// value is not a stop — so the encoding is undone here, once, at the only
// point where the two source kinds still differ.
fn decode_srgb(c: vec3<f32>) -> vec3<f32> {{
let lo = c / 12.92;
let hi = pow((max(c, vec3<f32>(0.04045)) + 0.055) / 1.055, vec3<f32>(2.4));
return select(hi, lo, c <= vec3<f32>(0.04045));
}}
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
let dims = textureDimensions(output);
@@ -301,17 +317,29 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
}}
{prologue}
// A non-linear source is already display-encoded; undo that so the
// operations below see linear colour whatever the source was.
let non_linear = u.as_shot_wb.w > 0.5;
if (non_linear) {{
c = decode_srgb(c);
}}
// As-shot white balance. Applied unconditionally, before any operation,
// because it is part of *interpreting* the sensor rather than an edit: a
// Bayer sensor's green photosites collect far more signal than its red
// and blue, so raw camera-space values are strongly green and no amount
// of later correction recovers a neutral image from them. The white
// balance operation, when active, applies its own offset on top of this.
//
// A non-linear source has already had this applied in-camera; the uniform
// is neutral there, so this is a multiply by one rather than a branch.
c = c * u.as_shot_wb.rgb;
{body}
// Camera space -> linear sRGB. Applied after the adjustments so white
// balance and exposure act on sensor-native values, which is where they
// are physically meaningful.
//
// Identity for a non-linear source, which is already in sRGB primaries.
c = vec3<f32>(
dot(u.cam_to_srgb_0.rgb, c),
dot(u.cam_to_srgb_1.rgb, c),