Carry the photograph's header into an export made from develop

The same file exported from the library grid kept its camera, its lens,
its capture date and its rights statement. Exported from the develop
button it kept none of them, and `{date}` in a filename template
resolved to nothing at all. Two buttons, one photograph, two different
files -- and the develop one was the version the photographer had just
finished working on.

A session now remembers the header it was opened from, and
`open_session` takes that header rather than the orientation read out of
it, so a photograph cannot be opened for editing without saying which
file it came from. `render_open_frame` clones it onto
`Source::Rendered`; both arms of `export_one` -- the worker's own decode
and the frame handed over already rendered -- turn a header into a
`{date}` and a `SourceMetadata` through the same function, so the two
paths cannot come to different readings of one file. What of it actually
reaches the exported bytes is still decided inside `dr-export` from the
settings, which is what keeps the location-stripping option working here
rather than giving it a second implementation to disagree with.

The alternative was to hang the metadata on `Source::Rendered` alone and
keep it beside the session in the interface. That touches less, but it
makes the header and the pixels two cells to hold in step across the six
places an image is opened, replaced or fails to open, and the failure
mode of getting that pairing wrong is not a missing tag: it is one
photograph exported under another's byline and coordinates, silently.
Kept on the session, the two travel together or not at all.

The header is stored decoded rather than transcribed at open time,
deliberately. `dr-export` argues that source metadata is a parameter and
not a field on `Frame`, because two exports of one frame may legitimately
disclose different amounts; by the same reasoning a session may remember
where its pixels came from without that being a decision about what to
publish, and the allowlist that decides remains the single function in
`export.rs`.

A file with no header is left with none -- an empty `{date}` and nothing
for the encoder to copy -- rather than today's date standing in for a
capture time nobody recorded.
This commit is contained in:
2026-08-30 21:28:31 +02:00
parent 353382c07f
commit 6acc98baad
6 changed files with 451 additions and 96 deletions
+55
View File
@@ -699,6 +699,34 @@ pub struct DevelopSession {
/// upright thumbnail. On anything shot in portrait the two differ by a
/// quarter turn, and matching them without undoing it finds nothing.
orientation: dr_types::Orientation,
/// TRACES: FR-EXP-8
/// What the file these pixels came from said about itself.
///
/// A session is one photograph, and this is that photograph's header: the
/// body, the lens, the moment the shutter fired, the rights statement.
/// Nothing in develop reads it. It is remembered so that an export made
/// from the open image can disclose the same things an export of the same
/// file from the grid does, and so `{date}` can mean the capture date on
/// both paths rather than nothing on one of them.
///
/// **Why here rather than beside the frame on `export::Source::Rendered`.**
/// Hanging it on the export request would work and would touch less of
/// this file, but the header would then have to be held somewhere in the
/// interface *alongside* the session and paired with it at export time —
/// two cells to keep in step across the six places a photograph is opened,
/// replaced or fails to open. The failure mode of getting that pairing
/// wrong is not a missing tag: it is one photograph exported under
/// another's byline and coordinates, silently. Kept here, the header
/// arrives with the pixels it belongs to or not at all, and there is no
/// pairing left to break.
///
/// It is the *decoded header* and not a `dr_export::SourceMetadata`, which
/// matters: what an export may disclose is a decision taken per export
/// from the settings, inside `dr-export` — see that crate's note on why
/// source metadata is a parameter and not a field on `Frame`. This is only
/// the memory of where the pixels came from; the allowlist that turns it
/// into something writable stays the one function in `export.rs`.
source_meta: Option<dr_decode::Metadata>,
/// Kept so the session can build GPU resources after construction.
///
/// The distance fields behind a subject mask are made when a layer is
@@ -921,6 +949,11 @@ impl DevelopSession {
id: SessionId::next(),
face_names: Vec::new(),
orientation,
// Filled by `crate::open_session`, which is the only place that
// has both the bytes and the header read from them. A session
// built straight from pixels — a test, `masks_ui`'s fixture —
// honestly has no header, and says so.
source_meta: None,
ctx: ctx.clone(),
graph,
history,
@@ -3327,6 +3360,28 @@ impl DevelopSession {
self.adjust.release_caches();
}
/// TRACES: FR-EXP-8
/// Remember what the file this session was opened from said about itself.
///
/// Called by [`crate::open_session`] rather than by the constructors,
/// because that is the one function that reads a photograph's bytes and
/// its header together — every other way of making a session starts from
/// pixels that never had a file behind them.
pub fn set_source_metadata(&mut self, meta: dr_decode::Metadata) {
self.source_meta = Some(meta);
}
/// TRACES: FR-EXP-8
/// The header this session was opened from, where there was one.
///
/// `None` is a real answer and not a failure: a JPEG with no EXIF block, a
/// file opened from bytes whose header would not parse, a session built in
/// a test. An export from such a session writes only what `dr-export` says
/// about itself, and in particular invents no capture date.
pub fn source_metadata(&self) -> Option<&dr_decode::Metadata> {
self.source_meta.as_ref()
}
/// The displayed size, for sizing the viewport.
///
/// The *framed* size, not the sensor's: cropping and quarter turns change