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:
+2
-2
@@ -144,7 +144,7 @@ A star has to take the press without it also reaching the cell, or every rating
|
|||||||
|
|
||||||
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:4032`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:3923`</sub>
|
||||||
|
|
||||||
### Select everything the grid is showing
|
### Select everything the grid is showing
|
||||||
|
|
||||||
@@ -153,4 +153,4 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
|
|||||||
|
|
||||||
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
|
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:4049`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:3940`</sub>
|
||||||
|
|||||||
+63
-63
File diff suppressed because one or more lines are too long
@@ -699,6 +699,34 @@ pub struct DevelopSession {
|
|||||||
/// upright thumbnail. On anything shot in portrait the two differ by a
|
/// upright thumbnail. On anything shot in portrait the two differ by a
|
||||||
/// quarter turn, and matching them without undoing it finds nothing.
|
/// quarter turn, and matching them without undoing it finds nothing.
|
||||||
orientation: dr_types::Orientation,
|
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.
|
/// Kept so the session can build GPU resources after construction.
|
||||||
///
|
///
|
||||||
/// The distance fields behind a subject mask are made when a layer is
|
/// The distance fields behind a subject mask are made when a layer is
|
||||||
@@ -921,6 +949,11 @@ impl DevelopSession {
|
|||||||
id: SessionId::next(),
|
id: SessionId::next(),
|
||||||
face_names: Vec::new(),
|
face_names: Vec::new(),
|
||||||
orientation,
|
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(),
|
ctx: ctx.clone(),
|
||||||
graph,
|
graph,
|
||||||
history,
|
history,
|
||||||
@@ -3327,6 +3360,28 @@ impl DevelopSession {
|
|||||||
self.adjust.release_caches();
|
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 displayed size, for sizing the viewport.
|
||||||
///
|
///
|
||||||
/// The *framed* size, not the sensor's: cropping and quarter turns change
|
/// The *framed* size, not the sensor's: cropping and quarter turns change
|
||||||
|
|||||||
+278
-21
@@ -50,7 +50,10 @@
|
|||||||
//! the file for itself would export the *saved* version rather than the one on
|
//! the file for itself would export the *saved* version rather than the one on
|
||||||
//! screen. That one frame is therefore rendered by the caller and handed over
|
//! screen. That one frame is therefore rendered by the caller and handed over
|
||||||
//! as [`Source::Rendered`]; everything after the render still moves off the UI
|
//! as [`Source::Rendered`]; everything after the render still moves off the UI
|
||||||
//! thread.
|
//! thread. It travels with the header its session was opened from, so an
|
||||||
|
//! export made from the develop button discloses exactly what an export of the
|
||||||
|
//! same file from the grid does — subject, both times, to what the settings
|
||||||
|
//! allow (FR-EXP-8).
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -461,6 +464,18 @@ pub enum Source {
|
|||||||
/// See the module docs for why this one cannot be left to the worker.
|
/// See the module docs for why this one cannot be left to the worker.
|
||||||
Rendered {
|
Rendered {
|
||||||
stem: String,
|
stem: String,
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// What the photograph's own file said about itself, as the session
|
||||||
|
/// remembers it — see [`crate::DevelopSession::source_metadata`].
|
||||||
|
///
|
||||||
|
/// The header travels rather than a finished
|
||||||
|
/// [`dr_export::SourceMetadata`], so the allowlist below stays the one
|
||||||
|
/// place a source field becomes exportable, and the two arms of
|
||||||
|
/// [`export_one`] reach the encoder through the same transcription.
|
||||||
|
///
|
||||||
|
/// `None` where the file had no header to read, which is an ordinary
|
||||||
|
/// outcome and not an error.
|
||||||
|
header: Option<dr_decode::Metadata>,
|
||||||
frame: dr_export::Frame,
|
frame: dr_export::Frame,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -617,14 +632,32 @@ fn export_one(
|
|||||||
cancel: &Cancel,
|
cancel: &Cancel,
|
||||||
) -> Option<Result<Placed, ItemError>> {
|
) -> Option<Result<Placed, ItemError>> {
|
||||||
// TRACES: FR-EXP-8
|
// TRACES: FR-EXP-8
|
||||||
// The fourth element is what the photograph's own file said about itself.
|
// The second and fourth elements are what the photograph's own file said
|
||||||
// A library image is decoded here, so it has one; a frame handed over
|
// about itself: the capture date the `{date}` token names, and the record
|
||||||
// already rendered does not — the develop session holds pixels and an edit
|
// the encoder may write from. A library image is decoded here and a
|
||||||
// graph, not the header they came from, so an export from the develop
|
// rendered one arrives with the header its session was opened from, and
|
||||||
// button carries only what `dr-export` writes about itself until that is
|
// both go through [`from_header`] — the same reading, so the same
|
||||||
// plumbed through the session.
|
// photograph exported from the two buttons cannot come out saying
|
||||||
|
// different things about itself.
|
||||||
let (stem, date, frame, source_metadata) = match source {
|
let (stem, date, frame, source_metadata) = match source {
|
||||||
Source::Rendered { stem, frame } => (stem, String::new(), frame, None),
|
Source::Rendered {
|
||||||
|
stem,
|
||||||
|
header,
|
||||||
|
frame,
|
||||||
|
} => {
|
||||||
|
// A file with no header stays a file with no header: an empty
|
||||||
|
// `{date}` and nothing for the encoder to copy. Substituting
|
||||||
|
// today's date here would put a lie in the filename of every
|
||||||
|
// scanned negative.
|
||||||
|
let (date, carried) = match header {
|
||||||
|
Some(meta) => {
|
||||||
|
let (date, carried) = from_header(&meta);
|
||||||
|
(date, Some(carried))
|
||||||
|
}
|
||||||
|
None => (String::new(), None),
|
||||||
|
};
|
||||||
|
(stem, date, frame, carried)
|
||||||
|
}
|
||||||
Source::Library { path, cache } => {
|
Source::Library { path, cache } => {
|
||||||
match render_from_library(request, &path, cache, cancel)? {
|
match render_from_library(request, &path, cache, cancel)? {
|
||||||
Ok(rendered) => rendered,
|
Ok(rendered) => rendered,
|
||||||
@@ -644,6 +677,28 @@ fn export_one(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// What a source file's header contributes to an export.
|
||||||
|
///
|
||||||
|
/// Two things, and they are read together because they come from the same
|
||||||
|
/// place: the `{date}` token, and the record the encoder writes from. One
|
||||||
|
/// function for both kinds of source, so that a develop export and a library
|
||||||
|
/// export of the same photograph cannot disagree about the day it was taken or
|
||||||
|
/// about which of its tags travel — the failure this consolidates away is the
|
||||||
|
/// one where a second reading is added beside the first and only one of them
|
||||||
|
/// is kept up to date.
|
||||||
|
///
|
||||||
|
/// `{date}` is the capture date, not today's: a template naming exports by
|
||||||
|
/// when the shutter fired is the reason the token exists. A file that recorded
|
||||||
|
/// no capture time gets an empty string, never a substitute.
|
||||||
|
fn from_header(meta: &dr_decode::Metadata) -> (String, dr_export::SourceMetadata) {
|
||||||
|
let date = meta
|
||||||
|
.captured_at
|
||||||
|
.map(crate::library_ui::format_date)
|
||||||
|
.unwrap_or_default();
|
||||||
|
(date, carried_metadata(meta))
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-EXP-8
|
/// TRACES: FR-EXP-8
|
||||||
/// What an export is allowed to carry from the file it was decoded from.
|
/// What an export is allowed to carry from the file it was decoded from.
|
||||||
///
|
///
|
||||||
@@ -734,15 +789,9 @@ fn render_from_library(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
|
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
|
||||||
let orientation = meta.orientation.unwrap_or_default();
|
let (date, carried) = from_header(&meta);
|
||||||
// `{date}` is the capture date, not today's: a template naming exports by
|
|
||||||
// when the shutter fired is the reason the token exists.
|
|
||||||
let date = meta
|
|
||||||
.captured_at
|
|
||||||
.map(crate::library_ui::format_date)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let mut session = match crate::open_session(gpu, &bytes, orientation) {
|
let mut session = match crate::open_session(gpu, &bytes, &meta) {
|
||||||
Ok(session) => session,
|
Ok(session) => session,
|
||||||
Err(e) => return Some(Err(ItemError::Open(e))),
|
Err(e) => return Some(Err(ItemError::Open(e))),
|
||||||
};
|
};
|
||||||
@@ -775,11 +824,12 @@ fn render_from_library(
|
|||||||
.unwrap_or_else(|| "export".into());
|
.unwrap_or_else(|| "export".into());
|
||||||
|
|
||||||
// TRACES: FR-EXP-8
|
// TRACES: FR-EXP-8
|
||||||
// `meta` was read at the top of this function for the orientation and the
|
// `meta` was read at the top of this function — for the orientation the
|
||||||
// `{date}` token; carrying it on to the encoder is what puts the camera,
|
// session opens with as much as for this — and carrying it on to the
|
||||||
// the lens and the rights statement into the exported file. What is
|
// encoder is what puts the camera, the lens and the rights statement into
|
||||||
// *dropped* from it is decided in `dr-export` from the settings, not here.
|
// the exported file. What is *dropped* from it is decided in `dr-export`
|
||||||
Some(Ok((stem, date, frame, Some(carried_metadata(&meta)))))
|
// from the settings, not here.
|
||||||
|
Some(Ok((stem, date, frame, Some(carried))))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Name, encode and write one rendered frame.
|
/// Name, encode and write one rendered frame.
|
||||||
@@ -1366,6 +1416,205 @@ mod tests {
|
|||||||
.expect("a batch always says it has finished")
|
.expect("a batch always says it has finished")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// What an export says about the photograph (FR-EXP-8)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A header of the kind a camera writes, GPS included.
|
||||||
|
///
|
||||||
|
/// The capture time is 2013-06-28 23:32:54 UTC, which `dr-export`'s own
|
||||||
|
/// round-trip test uses for the same reason: it is a date nothing else in
|
||||||
|
/// the pipeline could have produced by accident.
|
||||||
|
fn header() -> dr_decode::Metadata {
|
||||||
|
dr_decode::Metadata {
|
||||||
|
make: Some("Canon".into()),
|
||||||
|
model: Some("EOS 6D".into()),
|
||||||
|
lens: Some("EF 35mm f/2 IS USM".into()),
|
||||||
|
shutter: Some(1.0 / 250.0),
|
||||||
|
aperture: Some(2.8),
|
||||||
|
iso: Some(400),
|
||||||
|
focal_length: Some(35.0),
|
||||||
|
captured_at: Some(1_372_462_374),
|
||||||
|
captured_offset: Some(120),
|
||||||
|
artist: Some("A Photographer".into()),
|
||||||
|
copyright: Some("(c) A Photographer".into()),
|
||||||
|
location: dr_types::Location::new(48.8582, 2.2945, Some(35.0)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings that write JPEGs into `dir`.
|
||||||
|
///
|
||||||
|
/// JPEG rather than the PNG `to_folder` above uses. `dr-export` writes the
|
||||||
|
/// same EXIF block into a PNG's `eXIf` chunk, but `dr-decode` reads a
|
||||||
|
/// header out of a JPEG, so this is the format where the assertion can be
|
||||||
|
/// made against what a reader actually finds rather than against the bytes
|
||||||
|
/// this crate just wrote.
|
||||||
|
fn to_folder_as_jpeg(dir: &Path) -> ExportSettings {
|
||||||
|
ExportSettings {
|
||||||
|
format: dr_types::ExportFormat::Jpeg,
|
||||||
|
target: ExportTarget::Device,
|
||||||
|
destination: dir.to_string_lossy().into_owned(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one already-rendered frame through the batch, as the develop button
|
||||||
|
/// does, and hand back what landed.
|
||||||
|
fn develop_export(
|
||||||
|
settings: &ExportSettings,
|
||||||
|
header: Option<dr_decode::Metadata>,
|
||||||
|
stem: &str,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let messages = drive(
|
||||||
|
request(
|
||||||
|
settings.clone(),
|
||||||
|
vec![Source::Rendered {
|
||||||
|
stem: stem.into(),
|
||||||
|
header,
|
||||||
|
frame: frame(16, 12),
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
Cancel::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(finished(&messages), (1, 0, false), "the export itself");
|
||||||
|
|
||||||
|
let written = messages
|
||||||
|
.iter()
|
||||||
|
.find_map(|m| match m {
|
||||||
|
BatchMessage::Item {
|
||||||
|
outcome: Ok(Placed::Device(path)),
|
||||||
|
..
|
||||||
|
} => Some(path.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("a device export names the file it wrote");
|
||||||
|
std::fs::read(written).expect("the file the batch says it wrote")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_develop_export_writes_the_file_a_library_export_would_have() {
|
||||||
|
// The two buttons on the same photograph. The library path renders the
|
||||||
|
// frame itself and then hands the encoder `(date, carried_metadata)`;
|
||||||
|
// the develop path hands over a frame already rendered. Everything
|
||||||
|
// after that is shared, so if the develop path's file is byte for byte
|
||||||
|
// what the library path's arguments produce, the two cannot differ in
|
||||||
|
// what they disclose — which is the whole of this bug.
|
||||||
|
let dir = tmp();
|
||||||
|
let out = dir.join("exports");
|
||||||
|
let settings = to_folder_as_jpeg(&out);
|
||||||
|
|
||||||
|
let written = develop_export(&settings, Some(header()), "IMG_0001");
|
||||||
|
|
||||||
|
let (_, carried) = from_header(&header());
|
||||||
|
let library = dr_export::export(
|
||||||
|
&frame(16, 12),
|
||||||
|
&settings,
|
||||||
|
"IMG_0001.jpg".into(),
|
||||||
|
Some(&carried),
|
||||||
|
)
|
||||||
|
.expect("the same frame and the same settings");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
written, library.bytes,
|
||||||
|
"a develop export and a library export of one photograph differ"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And it is a header a reader can find, not merely bytes that match:
|
||||||
|
// an empty block on both sides would satisfy the comparison above.
|
||||||
|
let read_back = dr_decode::metadata(&written).expect("our own JPEG");
|
||||||
|
assert_eq!(read_back.make.as_deref(), Some("Canon"));
|
||||||
|
assert_eq!(read_back.model.as_deref(), Some("EOS 6D"));
|
||||||
|
assert_eq!(read_back.captured_at, header().captured_at);
|
||||||
|
assert_eq!(read_back.copyright, header().copyright);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_date_token_on_a_develop_export_is_when_the_shutter_fired() {
|
||||||
|
// Not today's date. A photographer filing an export of a negative
|
||||||
|
// scanned this morning wants 1978 in the name, and the library path
|
||||||
|
// has always given them that.
|
||||||
|
let dir = tmp();
|
||||||
|
let out = dir.join("exports");
|
||||||
|
let mut settings = to_folder_as_jpeg(&out);
|
||||||
|
settings.filename_template = "{date}_{name}".into();
|
||||||
|
|
||||||
|
develop_export(&settings, Some(header()), "IMG_0001");
|
||||||
|
assert!(
|
||||||
|
out.join("2013-06-28_IMG_0001.jpg").exists(),
|
||||||
|
"{:?}",
|
||||||
|
std::fs::read_dir(&out).unwrap().flatten().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_develop_export_obeys_the_location_setting_the_same_way() {
|
||||||
|
// The stripping decision is `dr-export`'s and is taken from the
|
||||||
|
// settings; this path must reach it rather than pre-filtering, or the
|
||||||
|
// photographer who deliberately turned stripping off would find their
|
||||||
|
// coordinates dropped anyway on one of the two buttons.
|
||||||
|
let dir = tmp();
|
||||||
|
let out = dir.join("exports");
|
||||||
|
|
||||||
|
let mut stripped = to_folder_as_jpeg(&out);
|
||||||
|
stripped.strip_location = true;
|
||||||
|
let bytes = develop_export(&stripped, Some(header()), "IMG_0001");
|
||||||
|
assert_eq!(
|
||||||
|
dr_decode::metadata(&bytes).expect("our own JPEG").location,
|
||||||
|
None,
|
||||||
|
"coordinates survived an export that was told to strip them"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut kept = to_folder_as_jpeg(&out);
|
||||||
|
kept.strip_location = false;
|
||||||
|
let bytes = develop_export(&kept, Some(header()), "IMG_0002");
|
||||||
|
let read_back = dr_decode::metadata(&bytes)
|
||||||
|
.expect("our own JPEG")
|
||||||
|
.location
|
||||||
|
.expect("the position the settings asked to keep");
|
||||||
|
// Through degrees, minutes and seconds and back, so exactly is the
|
||||||
|
// wrong word; a metre is about 1e-5 degrees.
|
||||||
|
assert!((read_back.latitude - 48.8582).abs() < 1e-5, "{read_back:?}");
|
||||||
|
assert!((read_back.longitude - 2.2945).abs() < 1e-5, "{read_back:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_photograph_with_no_header_exports_and_invents_no_date() {
|
||||||
|
// `{date}` has nothing to say and says nothing. The alternative —
|
||||||
|
// quietly substituting today — would date every scan and every
|
||||||
|
// headerless JPEG to the afternoon it was exported, and the filename
|
||||||
|
// is exactly where that lie would be hardest to notice.
|
||||||
|
let dir = tmp();
|
||||||
|
let out = dir.join("exports");
|
||||||
|
let mut settings = to_folder_as_jpeg(&out);
|
||||||
|
settings.filename_template = "{date}{name}".into();
|
||||||
|
|
||||||
|
// A file with no EXIF block, which is what the develop path really
|
||||||
|
// hands over for one: `load_bytes` reads the header as
|
||||||
|
// `Metadata::default()` and the session remembers that, exactly as the
|
||||||
|
// library path does for the same file.
|
||||||
|
let bytes = develop_export(&settings, Some(dr_decode::Metadata::default()), "IMG_0001");
|
||||||
|
assert!(out.join("IMG_0001.jpg").exists());
|
||||||
|
assert_eq!(
|
||||||
|
dr_decode::metadata(&bytes)
|
||||||
|
.expect("our own JPEG")
|
||||||
|
.captured_at,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a session with no header at all — the fixture case, and a
|
||||||
|
// decoder that returned nothing. `dr-export` writes no EXIF segment
|
||||||
|
// rather than an empty one, which `dr-decode` reports as an error
|
||||||
|
// rather than as a blank header; both readings are "no date", and the
|
||||||
|
// point of the test is that neither is today's.
|
||||||
|
let bytes = develop_export(&settings, None, "IMG_0002");
|
||||||
|
assert!(out.join("IMG_0002.jpg").exists());
|
||||||
|
assert_eq!(
|
||||||
|
dr_decode::metadata(&bytes).ok().and_then(|m| m.captured_at),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_rendered_frame_reaches_the_destination_folder() {
|
fn a_rendered_frame_reaches_the_destination_folder() {
|
||||||
// The whole tail of the batch — name, size, sharpen, encode, write —
|
// The whole tail of the batch — name, size, sharpen, encode, write —
|
||||||
@@ -1377,6 +1626,7 @@ mod tests {
|
|||||||
to_folder(&out),
|
to_folder(&out),
|
||||||
vec![Source::Rendered {
|
vec![Source::Rendered {
|
||||||
stem: "IMG_0001".into(),
|
stem: "IMG_0001".into(),
|
||||||
|
header: None,
|
||||||
frame: frame(16, 12),
|
frame: frame(16, 12),
|
||||||
}],
|
}],
|
||||||
),
|
),
|
||||||
@@ -1404,10 +1654,12 @@ mod tests {
|
|||||||
vec![
|
vec![
|
||||||
Source::Rendered {
|
Source::Rendered {
|
||||||
stem: "IMG_0001".into(),
|
stem: "IMG_0001".into(),
|
||||||
|
header: None,
|
||||||
frame: frame(16, 12),
|
frame: frame(16, 12),
|
||||||
},
|
},
|
||||||
Source::Rendered {
|
Source::Rendered {
|
||||||
stem: "IMG_0001".into(),
|
stem: "IMG_0001".into(),
|
||||||
|
header: None,
|
||||||
frame: frame(16, 12),
|
frame: frame(16, 12),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1463,6 +1715,7 @@ mod tests {
|
|||||||
settings,
|
settings,
|
||||||
vec![Source::Rendered {
|
vec![Source::Rendered {
|
||||||
stem: "IMG_0001".into(),
|
stem: "IMG_0001".into(),
|
||||||
|
header: None,
|
||||||
frame: frame(8, 8),
|
frame: frame(8, 8),
|
||||||
}],
|
}],
|
||||||
),
|
),
|
||||||
@@ -1497,10 +1750,12 @@ mod tests {
|
|||||||
},
|
},
|
||||||
Source::Rendered {
|
Source::Rendered {
|
||||||
stem: "good-a".into(),
|
stem: "good-a".into(),
|
||||||
|
header: None,
|
||||||
frame: frame(8, 8),
|
frame: frame(8, 8),
|
||||||
},
|
},
|
||||||
Source::Rendered {
|
Source::Rendered {
|
||||||
stem: "good-b".into(),
|
stem: "good-b".into(),
|
||||||
|
header: None,
|
||||||
frame: frame(8, 8),
|
frame: frame(8, 8),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1529,6 +1784,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
Source::Rendered {
|
Source::Rendered {
|
||||||
stem: "b".into(),
|
stem: "b".into(),
|
||||||
|
header: None,
|
||||||
frame: frame(8, 8),
|
frame: frame(8, 8),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1564,6 +1820,7 @@ mod tests {
|
|||||||
to_folder(&out),
|
to_folder(&out),
|
||||||
vec![Source::Rendered {
|
vec![Source::Rendered {
|
||||||
stem: "IMG_0001".into(),
|
stem: "IMG_0001".into(),
|
||||||
|
header: None,
|
||||||
frame: frame(8, 8),
|
frame: frame(8, 8),
|
||||||
}],
|
}],
|
||||||
),
|
),
|
||||||
|
|||||||
+48
-5
@@ -183,12 +183,13 @@ fn load(ctx: Option<&dr_gpu::GpuContext>, path: &Path) -> Result<Loaded, String>
|
|||||||
fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded, String> {
|
fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded, String> {
|
||||||
let meta = dr_decode::metadata(bytes).unwrap_or_default();
|
let meta = dr_decode::metadata(bytes).unwrap_or_default();
|
||||||
|
|
||||||
// How the file stored its pixels. A file that says nothing is taken as
|
// How the file stored its pixels, for the preview path below — the develop
|
||||||
// upright — see `Orientation::from_exif`.
|
// path takes it from the same header inside `open_session`. A file that
|
||||||
|
// says nothing is taken as upright — see `Orientation::from_exif`.
|
||||||
let orientation = meta.orientation.unwrap_or_default();
|
let orientation = meta.orientation.unwrap_or_default();
|
||||||
|
|
||||||
if let Some(ctx) = ctx {
|
if let Some(ctx) = ctx {
|
||||||
match open_session(ctx, bytes, orientation) {
|
match open_session(ctx, bytes, &meta) {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
let (width, height) = session.source_size();
|
let (width, height) = session.source_size();
|
||||||
return Ok(Loaded {
|
return Ok(Loaded {
|
||||||
@@ -228,7 +229,7 @@ fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-EXP-7 | FR-RAW-4
|
/// TRACES: FR-EXP-7 | FR-EXP-8 | FR-RAW-4
|
||||||
/// Open bytes for editing, with no interface types involved.
|
/// Open bytes for editing, with no interface types involved.
|
||||||
///
|
///
|
||||||
/// Split out of [`load_bytes`] so the batch exporter can share it. That runs on
|
/// Split out of [`load_bytes`] so the batch exporter can share it. That runs on
|
||||||
@@ -236,7 +237,38 @@ fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded,
|
|||||||
/// and a second copy of the JPEG-versus-RAW routing is a second copy that would
|
/// and a second copy of the JPEG-versus-RAW routing is a second copy that would
|
||||||
/// drift, which is exactly how a batch comes to export something the viewer
|
/// drift, which is exactly how a batch comes to export something the viewer
|
||||||
/// would have shown differently.
|
/// would have shown differently.
|
||||||
|
///
|
||||||
|
/// Takes the decoded header rather than just the orientation out of it. Both
|
||||||
|
/// callers had already read it, and the session keeps it: this is the one
|
||||||
|
/// place a photograph becomes editable, so making the header an argument here
|
||||||
|
/// is what makes "a session knows which file it came from" true by
|
||||||
|
/// construction rather than by everybody remembering to say so.
|
||||||
pub(crate) fn open_session(
|
pub(crate) fn open_session(
|
||||||
|
ctx: &dr_gpu::GpuContext,
|
||||||
|
bytes: &[u8],
|
||||||
|
meta: &Metadata,
|
||||||
|
) -> Result<DevelopSession, String> {
|
||||||
|
// How the file stored its pixels. A file that says nothing is taken as
|
||||||
|
// upright — see `Orientation::from_exif`.
|
||||||
|
let mut session = open_pixels(ctx, bytes, meta.orientation.unwrap_or_default())?;
|
||||||
|
|
||||||
|
// TRACES: FR-EXP-8
|
||||||
|
// The header goes with the session rather than being read again later,
|
||||||
|
// and this function takes it rather than an orientation so that there is
|
||||||
|
// no way to open a photograph for editing without saying what file it came
|
||||||
|
// from. An export from the develop button carries the camera, the lens and
|
||||||
|
// the capture date because of this line; before it, the same photograph
|
||||||
|
// exported from the grid kept them and exported from develop did not.
|
||||||
|
session.set_source_metadata(meta.clone());
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode bytes into a session, with no header attached.
|
||||||
|
///
|
||||||
|
/// Split from [`open_session`] only so the routing below stays one expression
|
||||||
|
/// with two early returns; every caller wants the version that remembers where
|
||||||
|
/// the pixels came from.
|
||||||
|
fn open_pixels(
|
||||||
ctx: &dr_gpu::GpuContext,
|
ctx: &dr_gpu::GpuContext,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
orientation: dr_types::Orientation,
|
orientation: dr_types::Orientation,
|
||||||
@@ -481,7 +513,18 @@ fn render_open_frame(
|
|||||||
.map(|s| s.to_string_lossy().into_owned())
|
.map(|s| s.to_string_lossy().into_owned())
|
||||||
.unwrap_or_else(|| "export".into());
|
.unwrap_or_else(|| "export".into());
|
||||||
|
|
||||||
Ok(export::Source::Rendered { stem, frame })
|
// TRACES: FR-EXP-8
|
||||||
|
// The header the session was opened from, cloned because the frame is
|
||||||
|
// about to leave this thread. `None` where the file had none to read,
|
||||||
|
// which the worker takes as "say nothing" rather than as a reason to
|
||||||
|
// invent something.
|
||||||
|
let header = session.source_metadata().cloned();
|
||||||
|
|
||||||
|
Ok(export::Source::Rendered {
|
||||||
|
stem,
|
||||||
|
header,
|
||||||
|
frame,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-EXP-7
|
/// TRACES: FR-EXP-7
|
||||||
|
|||||||
@@ -3462,11 +3462,11 @@ fn open_native(
|
|||||||
gpu: &dr_gpu::GpuContext,
|
gpu: &dr_gpu::GpuContext,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
) -> Result<crate::develop::DevelopSession, String> {
|
) -> Result<crate::develop::DevelopSession, String> {
|
||||||
let orientation = dr_decode::metadata(bytes)
|
// Whatever the header says, or an empty one for a file that has none: the
|
||||||
.ok()
|
// session takes its orientation from it, and remembers the rest for
|
||||||
.and_then(|m| m.orientation)
|
// anything that later exports from this session (FR-EXP-8).
|
||||||
.unwrap_or_default();
|
let meta = dr_decode::metadata(bytes).unwrap_or_default();
|
||||||
crate::open_session(gpu, bytes, orientation)
|
crate::open_session(gpu, bytes, &meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why one original did not arrive.
|
/// Why one original did not arrive.
|
||||||
|
|||||||
Reference in New Issue
Block a user