Export the photograph, not the canvas
Zooming the develop view changed the exported file. `Framing::view` is kept out of the sidecar, out of `is_active` and out of `output_size` precisely so that it cannot — but those exclusions keep it out of the *edit*, and an export is a *render*. `visible_rect` deliberately folds the view into the single rect the fused shader's prologue samples, so `render_for_export` inherited it: at 4:1 it wrote the middle of the frame, magnified to fill the file at the full output size, with the detail kernels scaled four times over because `render_scale` folds the view in as well. `render_thumbnail` did the same to the grid. `render_uncropped` already suspends the view for this exact reason, so the fix is its pattern: one `render_the_file` that both file-producing paths go through, composing inside the suspension since the view reaches the shader as a uniform baked at composition time. Restored whatever happens — leaving the graph un-zoomed after a failed export would throw away where the photographer was looking. Nothing caught it because the guard checked the wrong things. `zooming_does_not_change_the_exported_image` asserted the output size and the crop; both held perfectly throughout. Renamed to `zooming_does_not_change_the_size_or_the_crop`, which is what it tests, and the pixels are now guarded where pixels exist. The new test uses a ramp rather than quadrants deliberately: a four-quadrant frame is self-similar under a centred zoom, and the first version of this test passed against the bug because of it. Traces FR-EXP-9, which asks for the full-quality pipeline "regardless of what the display was showing". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+45
-8
@@ -3371,13 +3371,53 @@ impl DevelopSession {
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let (w, h) = self.graph.output_size(sw, sh);
|
||||
|
||||
let shader = self.graph.compose_for(space);
|
||||
self.render_with_masks(&shader, w, h, space)?;
|
||||
|
||||
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
|
||||
let (pixels, rw, rh) = self.render_the_file(w, h, space)?;
|
||||
dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-9 | FR-CAT-9
|
||||
/// Render the *photograph*, with the viewport suspended, and read it back.
|
||||
///
|
||||
/// **The one thing separating a file from a frame on screen**, and the
|
||||
/// reason both file-producing paths go through here rather than composing
|
||||
/// for themselves. [`Framing::view`](../dr_pipeline/framing/struct.Framing.html#method.view)
|
||||
/// is not an edit — it is kept out of the sidecar, out of `is_active` and
|
||||
/// out of `output_size` precisely so that zooming cannot change what the
|
||||
/// file becomes. But it is folded into `visible_rect`, which is the rect
|
||||
/// the fused shader's prologue samples, so a path that composes the graph
|
||||
/// and renders it inherits the zoom whether or not it wanted it. Exporting
|
||||
/// at 4:1 wrote the middle of the frame magnified to fill the file, at the
|
||||
/// full output size, with the detail kernels scaled four times over —
|
||||
/// silently, since every dimension the old guard checked still held.
|
||||
///
|
||||
/// Suspended rather than refused: an export is a thing the photographer
|
||||
/// asks for *while* inspecting a highlight at 4×, and demanding they zoom
|
||||
/// out first would be answering a question nobody asked.
|
||||
///
|
||||
/// Restored whatever happens, for the reason [`Self::render_uncropped`]
|
||||
/// restores it: leaving the graph un-zoomed after a failed export would
|
||||
/// throw away where the photographer was looking.
|
||||
fn render_the_file(
|
||||
&mut self,
|
||||
w: u32,
|
||||
h: u32,
|
||||
space: dr_types::ColourSpace,
|
||||
) -> Result<(Vec<u8>, u32, u32), String> {
|
||||
let saved_view = self.graph.framing().view();
|
||||
self.graph.framing_mut().set_view(CropRect::default());
|
||||
|
||||
// Composed *inside* the suspension: the view reaches the shader as a
|
||||
// uniform baked at composition, so composing before this point would
|
||||
// restore the framing and export the zoom anyway.
|
||||
let shader = self.graph.compose_for(space);
|
||||
let rendered = self.render_with_masks(&shader, w, h, space);
|
||||
|
||||
self.graph.framing_mut().set_view(saved_view);
|
||||
rendered?;
|
||||
|
||||
self.adjust.export_pixels().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-9
|
||||
/// Render this edit small, for the grid's thumbnail.
|
||||
///
|
||||
@@ -3594,10 +3634,7 @@ impl DevelopSession {
|
||||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||||
let (w, h) = fit(fw, fh, edge.max(1), edge.max(1));
|
||||
|
||||
let shader = self.graph.compose_for(dr_types::ColourSpace::Srgb);
|
||||
self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?;
|
||||
|
||||
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
|
||||
let (pixels, rw, rh) = self.render_the_file(w, h, dr_types::ColourSpace::Srgb)?;
|
||||
Ok((rw, rh, pixels))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user