diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs
index 7c596c4a7..aff017892 100644
--- a/crates/merge/src/hdr.rs
+++ b/crates/merge/src/hdr.rs
@@ -1,8 +1,9 @@
//! HDR merge of exposure brackets.
//!
//! 1. **Order and reference.** Frames are ordered by brightness (EXIF exposure, or median
-//! luminance without EXIF); the middle one is the reference (geometry, colour and the output's
-//! default brightness come from it).
+//! luminance without EXIF; ties by content); the middle one is the reference (geometry, colour
+//! and the output's default brightness come from it). Everything below runs in that order, so
+//! the result is the same whatever order the frames come in.
//! 2. **Auto align** ([`crate::align`]): each frame is registered to the reference and resampled
//! into its geometry.
//! 3. **Exposure normalisation.** The exposure ratio between brightness-adjacent frames is
@@ -125,9 +126,25 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress)
Some(e) if e.iter().any(|v| (v - e[0]).abs() > 0.1) => e.clone(),
_ => medians.iter().map(|m| (*m as f64).log2()).collect(),
};
- let mut order: Vec = (0..n).collect();
- order.sort_by(|&a, &b| brightness[a].total_cmp(&brightness[b]).then(a.cmp(&b)));
- let reference = order[n / 2];
+ // ties (equal brightness) by content, never by position
+ let fingerprint: Vec =
+ frames.par_iter().map(|f| f.image.data.iter().step_by(997).map(|p| p[0] as f64 + 2.0 * p[1] as f64 + 3.0 * p[2] as f64).sum()).collect();
+ let mut input: Vec = (0..n).collect();
+ input.sort_by(|&a, &b| {
+ brightness[a].total_cmp(&brightness[b]).then(medians[a].total_cmp(&medians[b])).then(fingerprint[a].total_cmp(&fingerprint[b]))
+ });
+ // From here on frames are in brightness order (`input[p]` is the caller's index of position `p`): alignment seeds,
+ // sums and deghost masks no longer depend on the order the caller passed. Per-frame results go back at the end.
+ let mut slots: Vec