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:
2026-08-30 19:41:30 +02:00
co-authored by Claude Opus 5
parent a1e361e35a
commit 3d248cfb79
4 changed files with 213 additions and 35 deletions
+45 -8
View File
@@ -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))
}