-
Notifications
You must be signed in to change notification settings - Fork 52
Expand file tree
/
Copy pathdecode.rs
More file actions
3781 lines (3616 loc) · 180 KB
/
Copy pathdecode.rs
File metadata and controls
3781 lines (3616 loc) · 180 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! RAW decode + feature extraction (Milestone M1, decode half).
//!
//! Backed by `rawler` 0.7.2 — chosen for Sony A7R IV/IVA coverage, embedded
//! preview extraction, and full EXIF (see `docs/M1_PLAN.md` §1 and §9; the
//! older pure-Rust `rawloader` froze its camera DB before these bodies). One
//! backend for now: a `Decoder` trait abstraction is deferred until a second
//! backend is actually needed (the user shoots a single camera family).
//!
//! All `rawler` calls here were written against the crate's real source
//! (`RawSource::new`, `get_decoder`, the `Decoder` trait, `RawMetadata.exif`),
//! not from memory.
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use image::{DynamicImage, GenericImageView};
use rawler::decoders::RawDecodeParams;
use rawler::formats::tiff::reader::TiffReader;
use rawler::formats::tiff::{GenericTiffReader, Rational, SRational, Value};
use rawler::get_decoder;
use rawler::rawsource::RawSource;
use rawler::tags::TiffCommonTag;
/// Camera + capture metadata pulled from the RAW, for display and for feeding
/// the AI advisor later.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Meta {
pub make: String,
pub model: String,
pub lens: Option<String>,
pub iso: Option<u32>,
/// Human shutter string, e.g. "1/1250" or "2s".
pub shutter: Option<String>,
/// f-number, e.g. 4.0.
pub aperture: Option<f32>,
pub focal_length_mm: Option<f32>,
pub exposure_bias_ev: Option<f32>,
pub date_time: Option<String>,
/// Full sensor dimensions (from the raw image, not the preview).
pub width: usize,
pub height: usize,
/// As-shot white-balance multipliers [R, G1, B, G2].
pub as_shot_wb_coeffs: [f32; 4],
}
/// 256-bin per-channel + luma histogram with clipping fractions.
///
/// Computed from the camera-processed embedded preview (tone-mapped), so it is
/// a *display-referred* histogram — good for framing/clipping hints, not a
/// linear raw histogram. A raw-linear version can replace this in a later
/// milestone if exposure decisions need it.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Histogram {
pub luma: Vec<u32>,
pub r: Vec<u32>,
pub g: Vec<u32>,
pub b: Vec<u32>,
/// % of pixels with luma in 0..=1 (crushed blacks).
pub clip_black_pct: f32,
/// % of pixels with luma in 254..=255 (blown highlights).
pub clip_white_pct: f32,
pub sample_pixels: u64,
}
/// Everything `decode_raw` produces for one RAW file.
pub struct Decoded {
/// Full-resolution embedded preview (already white-balanced by the camera).
pub preview: DynamicImage,
pub meta: Meta,
pub histogram: Histogram,
/// Embedded XMP packet, if the RAW carries one.
pub embedded_xmp: Option<String>,
}
fn ratio(r: &Rational) -> f32 {
if r.d == 0 { 0.0 } else { r.n as f32 / r.d as f32 }
}
fn sratio(r: &SRational) -> f32 {
if r.d == 0 { 0.0 } else { r.n as f32 / r.d as f32 }
}
/// Format a shutter-speed Rational as "1/x" for fast speeds, "Ns" otherwise.
fn fmt_shutter(r: &Rational) -> String {
let v = ratio(r);
if v > 0.0 && v < 1.0 {
format!("1/{}", (1.0 / v).round() as i64)
} else {
format!("{v}s")
}
}
/// Every camera-RAW extension the app opens — THE list `is_raw` is, and the
/// one every hand-copied extension list in the tree derives from (the GUI file
/// dialog, the web `accept` attribute, the library scanners). A second copy is
/// drift waiting to happen: `.orf`/`.rw2`/`.raw` were openable for four
/// releases while the file dialog refused them.
///
/// **What is in it.** One entry per rawler 0.7.2 decoder that has a real
/// filename extension. rawler dispatches on CONTENT (magic bytes, then the
/// TIFF `Make` string — `decoders/mod.rs:847-994`), never on the extension, so
/// this list only has to name files worth handing it; the decoder choice is
/// rawler's. `3fr`/`fff` are Hasselblad (the `tfr` decoder), `mos` is Leaf,
/// `nrw` is Nikon's compact line, `mrw` Minolta, `ari` ARRI.
///
/// **What is deliberately NOT in it.**
/// * `x3f` (Sigma Foveon) — PERMANENTLY excluded. rawler 0.7.2's
/// `decoders/x3f.rs:138` (`format_dump`) and `:146` (`raw_metadata`) are
/// literal `todo!()`, i.e. a guaranteed panic, and we call `raw_metadata`
/// directly rather than through rawler's own `catch_unwind` wrapper. The
/// CLI guard added alongside this list ([`guard_parser_panic`]) turns that
/// into a named error instead of an abort, but a format whose metadata
/// reader cannot run at all has nothing to offer a photographer — listing
/// it would only promise support that provably does not exist. Revisit
/// when the upstream `todo!()`s become real code, not before.
/// * `nkd` / "unwrapped" — rawler decoders with no extension of their own
/// (CHDK-style naked dumps matched by FILE SIZE, `mod.rs:989-992`).
/// * `qtk` — QuickTake, matched by magic bytes; the extension is `.qtk` but
/// the format is a 1994 Apple curiosity with no develop path worth
/// claiming.
pub const RAW_EXTS: [&str; 24] = [
"arw", "dng", "raw", "raf", "nef", "cr2", "cr3", "orf", "rw2", "pef", "srw", "3fr", "fff",
"iiq", "mef", "mos", "erf", "kdc", "dcr", "dcs", "crw", "nrw", "mrw", "ari",
];
/// Does this path look like a camera RAW (vs an already-baked raster like a
/// LR/PS-exported PNG/TIFF/JPEG)? Drives the raw-vs-baked dispatch.
///
/// Accepting an extension is NOT a promise that the body decodes: rawler
/// carries 725 camera models and refuses anything outside them
/// ([`describe_decoder_failure`] turns that into a sentence a photographer can
/// act on). It IS a promise that the file reaches the RAW engine rather than
/// the baked one, which is the only decision this predicate makes.
pub fn is_raw(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| RAW_EXTS.iter().any(|x| e.eq_ignore_ascii_case(x)))
}
/// Transform profiled pixels into the sRGB working space the whole pipeline
/// assumes. A baked import that carries an ICC profile (LR "Edit in…" exports
/// ProPhoto 16-bit TIFFs by default) used to be read as if it were sRGB, so
/// the histogram, tone and HSL decisions were all computed on wrong numbers.
/// Unsupported layouts and unparseable profiles are hard errors, not a silent
/// fall-through to "assume sRGB" — that fall-through IS the bug, and in a
/// batch run it would pay for a grade computed from incorrect colors.
fn apply_icc_profile(img: &mut DynamicImage, profile: &[u8], path: &Path) -> Result<()> {
let input = qcms::Profile::new_from_slice(profile, false)
.ok_or_else(|| anyhow!("invalid ICC profile in {}", path.display()))?;
let output = qcms::Profile::new_sRGB();
let color_type = img.color();
let (pixels, data_type): (&mut [u8], qcms::DataType) = match img {
DynamicImage::ImageRgb8(rgb) => {
(rgb.as_flat_samples_mut().samples, qcms::DataType::RGB8)
}
DynamicImage::ImageRgba8(rgba) => {
(rgba.as_flat_samples_mut().samples, qcms::DataType::RGBA8)
}
// 16-bit is the MAIN customer: LR "Edit in…" hands over ProPhoto
// 16-bit TIFFs, and refusing them here reintroduced the exact
// workflow this function exists to serve (they opened fine — with
// wrong colours — before the ICC pass landed).
DynamicImage::ImageRgb16(_) | DynamicImage::ImageRgba16(_) => {
return apply_icc_profile_16(img, &input, &output, path);
}
_ => anyhow::bail!(
"ICC profile in {} accompanies {color_type:?} pixels, but qcms has no matching \
transform that preserves this image's channel layout and bit depth",
path.display()
),
};
let transform = qcms::Transform::new(
&input,
&output,
data_type,
qcms::Intent::Perceptual,
)
.ok_or_else(|| {
anyhow!(
"ICC profile in {} cannot transform {color_type:?} pixels into sRGB",
path.display()
)
})?;
transform.apply(pixels);
Ok(())
}
/// The 16-bit arm of [`apply_icc_profile`]. qcms transforms 8-bit samples
/// only (DataType is RGB8/RGBA8/BGRA8/Gray8/GrayA8 — checked against qcms
/// 0.3's source), and rounding the IMAGE to 8 bits would trade the
/// colour-space error for permanent banding in every later tone move. So:
/// run the profile pair ONCE over a 33³ RGB lattice at qcms's native 8-bit
/// precision, then map the 16-bit samples through that lattice by trilinear
/// interpolation in f32. The colour mapping is 8-bit-accurate (≤1/255 per
/// lattice value — the transform's own output precision) while the DATA
/// keeps its 16-bit smoothness, because interpolation is continuous between
/// lattice points. ICC display-class transforms are smooth by construction,
/// so 33 points per axis track them closely.
fn apply_icc_profile_16(
img: &mut DynamicImage,
input: &qcms::Profile,
output: &qcms::Profile,
path: &Path,
) -> Result<()> {
const N: usize = 33;
let mut lattice = vec![0u8; N * N * N * 3];
for r in 0..N {
for g in 0..N {
for b in 0..N {
let i = ((r * N + g) * N + b) * 3;
lattice[i] = (r * 255 / (N - 1)) as u8;
lattice[i + 1] = (g * 255 / (N - 1)) as u8;
lattice[i + 2] = (b * 255 / (N - 1)) as u8;
}
}
}
let transform = qcms::Transform::new(
input,
output,
qcms::DataType::RGB8,
qcms::Intent::Perceptual,
)
.ok_or_else(|| {
anyhow!(
"ICC profile in {} cannot transform RGB pixels into sRGB",
path.display()
)
})?;
transform.apply(&mut lattice);
let sample = |rgb: [u16; 3]| -> [u16; 3] {
let mut idx = [0usize; 3];
let mut frac = [0f32; 3];
for (c, v) in rgb.iter().enumerate() {
let t = f32::from(*v) / 65535.0 * (N - 1) as f32;
let i = (t as usize).min(N - 2);
idx[c] = i;
frac[c] = t - i as f32;
}
let at = |dr: usize, dg: usize, db: usize, ch: usize| -> f32 {
let i = (((idx[0] + dr) * N + idx[1] + dg) * N + idx[2] + db) * 3 + ch;
f32::from(lattice[i])
};
let mut out = [0u16; 3];
for (ch, o) in out.iter_mut().enumerate() {
let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
let c00 = lerp(at(0, 0, 0, ch), at(1, 0, 0, ch), frac[0]);
let c10 = lerp(at(0, 1, 0, ch), at(1, 1, 0, ch), frac[0]);
let c01 = lerp(at(0, 0, 1, ch), at(1, 0, 1, ch), frac[0]);
let c11 = lerp(at(0, 1, 1, ch), at(1, 1, 1, ch), frac[0]);
let c0 = lerp(c00, c10, frac[1]);
let c1 = lerp(c01, c11, frac[1]);
let v = lerp(c0, c1, frac[2]) / 255.0;
*o = (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
}
out
};
match img {
DynamicImage::ImageRgb16(rgb) => {
for px in rgb.as_flat_samples_mut().samples.chunks_exact_mut(3) {
let [r, g, b] = sample([px[0], px[1], px[2]]);
px.copy_from_slice(&[r, g, b]);
}
}
DynamicImage::ImageRgba16(rgba) => {
// Alpha is coverage, not colour — it rides through untouched.
for px in rgba.as_flat_samples_mut().samples.chunks_exact_mut(4) {
let [r, g, b] = sample([px[0], px[1], px[2]]);
px[..3].copy_from_slice(&[r, g, b]);
}
}
_ => unreachable!("routed here only for Rgb16/Rgba16"),
}
Ok(())
}
const MAX_ALLOC: u64 = 4 * 1024 * 1024 * 1024;
/// The accept/reject decision for a decode's peak allocation, extracted so a
/// test can pin the BOUNDARY: equality refuses (`>` instead of `>=` would
/// admit an exact-ceiling allocation and abort the process).
fn allocation_over_ceiling(need: u64) -> bool {
need >= MAX_ALLOC
}
/// Peak output-buffer bytes for a decode followed by `apply_orientation`: the
/// four rotating variants allocate a SECOND full buffer (`rotate90()` /
/// `rotate270()` return new images; 180°/flips are in-place), so the old
/// `total_bytes()`-only check let peak storage reach twice the 4 GiB ceiling.
fn decode_peak_bytes(need: u64, orientation: image::metadata::Orientation) -> u64 {
if matches!(
orientation,
image::metadata::Orientation::Rotate90
| image::metadata::Orientation::Rotate270
| image::metadata::Orientation::Rotate90FlipH
| image::metadata::Orientation::Rotate270FlipH
) {
need.saturating_mul(2)
} else {
need
}
}
/// Bytes per SOURCE pixel the baked develop chain holds ON TOP of the decoded
/// buffer at its peak (`render_baked_to_image`). The f32 planes
/// (`Vec<[f32; 3]>`, 12 B/px) are alive from the transcode to the end of the
/// function; what rides ALONGSIDE them is what varies, and the peak is the
/// worst of those moments — enumerated as data (not prose) by
/// `develop_peak_accounts_for_every_pass`, which is where a new stage lands.
///
/// Three of those moments tie at 24: a spatial pass (12 + the luma plane 4 +
/// `blur_plane`'s two chained planes 8), a geometric resample (12 + the Rgb16
/// frame 6 + the resampler's fresh output 6) and the AI-denoise round trip.
/// The chain has been tuned to that ceiling deliberately (the A7 batch), so
/// "12 + 6 + 6" is one path to 24 rather than the whole account.
///
/// A further full-frame stage raises this constant only if it is alive AT THE
/// SAME TIME as the planes: three SEQUENTIAL unsharp passes (clarity, texture,
/// sharpening) each drop their two planes before the next allocates
/// (`render.rs`'s memory note on `apply_masks`), so adding one costs nothing
/// here. A stage that holds a new buffer concurrently costs its full width.
///
/// **R27 R7 — re-derived, not re-assumed, when the RAW format list went from 9
/// extensions to 24.** The worry raised was "this was tuned on a 61 MP Bayer
/// A7; what about a 100 MP GFX or a Phase One back?". Two facts answer it:
///
/// 1. **It never applies to a RAW at all.** The only consumer is
/// [`develop_peak_bytes`], reached only from `load_image_gated(develop =
/// true)` — and that gate REFUSES a camera RAW by name a few lines in. A
/// RAW's develop is budgeted by the decode-scope lifetimes in
/// `render::render_to_image_in`, not here. So sensor size and CFA layout
/// (Bayer vs X-Trans vs 4-colour) are outside this constant's world; what
/// it budgets is a baked PNG/TIFF/JPEG, which has no CFA by definition.
/// 2. **It is per-SOURCE-PIXEL, so it does not carry a resolution
/// assumption.** Every term above is a fixed number of bytes for one pixel
/// (12 B of `[f32; 3]`, 4 B luma, 6 B of Rgb16, …); the pixel COUNT is the
/// other multiplicand in `develop_peak_bytes` and comes from the file's own
/// header. Doubling the megapixels doubles the product, which is the
/// intended behaviour — it is what makes the 4 GiB ceiling bite on a
/// 200 MP panorama and not on a 24 MP export.
///
/// What "tuned to that ceiling deliberately (the A7 batch)" refers to is WHICH
/// STAGES exist and which of them overlap — the enumeration in
/// `develop_peak_accounts_for_every_pass` — not a pixel count. That
/// enumeration is what a new stage must update; the 61 MP number never enters
/// the arithmetic.
const PIPELINE_BYTES_PER_PIXEL: u64 = 24;
/// [`decode_peak_bytes`] plus the develop chain's own full-frame buffers —
/// what the baked develop entry point really allocates for a source of
/// `pixels` px. Pure, so the boundary test can pin the accounting.
fn develop_peak_bytes(
decoded_bytes: u64,
orientation: image::metadata::Orientation,
pixels: u64,
) -> u64 {
decode_peak_bytes(decoded_bytes, orientation)
.saturating_add(pixels.saturating_mul(PIPELINE_BYTES_PER_PIXEL))
}
/// Bytes of COMMIT charge one SOURCE pixel of a camera RAW costs at the PEAK of
/// `render::render_to_image_in` — the RAW twin of [`PIPELINE_BYTES_PER_PIXEL`],
/// and what lets the same 4 GiB per-file ceiling be charged to a RAW at all.
///
/// **MEASURED, not derived.** 1,771 MB of peak process commit over a
/// 9504x6336 (60.2 MP) A7R V ARW = 30.8 B/px, read as `PeakPagefileUsage` by
/// `jobs::tests::probe_per_photo_peak_commit` (release profile, one stage per
/// process — that probe's own docs carry the method, `jobs`' module docs the
/// stage table). Rounded UP to 31: this number multiplies a pixel count into a
/// REFUSAL, and rounding a peak DOWN is how a file that really does blow the
/// ceiling gets admitted.
///
/// It is the WHOLE develop's peak, not one buffer's width. The demosaiced f32
/// frame (`Vec<[f32; 3]>`, 12 B/px) is only its largest single term; rawler's
/// own sensor buffers, the mapped file, the orientation transient and the
/// 16-bit pack are all inside the measured figure. That is deliberate — the
/// baked side can enumerate its overlapping stages because it OWNS them
/// (`PIPELINE_BYTES_PER_PIXEL`'s doc does exactly that), while a RAW's peak is
/// mostly inside rawler, where an enumeration would be a guess about somebody
/// else's allocations. A measurement of the whole is the honest form.
///
/// **Corpus caveat, stated rather than hidden**: one body, one CFA, one
/// compression mode. A format whose decoder holds more than rawler's ARW path
/// does would peak higher per pixel, and this constant would then admit a file
/// it should refuse. Re-run the probe when the RAW format list grows a decoder
/// with a different shape (the same discipline `PIPELINE_BYTES_PER_PIXEL`'s
/// R27 R7 note records for the baked side).
pub const RAW_DEVELOP_BYTES_PER_PIXEL: u64 = 31;
/// What developing a `pixels`-pixel camera RAW peaks at. Pure, so the boundary
/// test can pin the accounting the way it pins the baked one.
fn raw_develop_peak_bytes(pixels: u64) -> u64 {
pixels.saturating_mul(RAW_DEVELOP_BYTES_PER_PIXEL)
}
/// THE RAW DEVELOP CEILING — the twin of the baked gate inside
/// [`load_image_gated`], and the closing of what the R28 adjudication (F2)
/// called the deeper root: the baked path has refused an over-ceiling file
/// since L02, while **the RAW path had no per-file limit at all**. A 150 MP
/// IIQ on the default `batch --jobs 3` was therefore a worse instance of the
/// same defect than the constructed TIFF scenario that raised it, with nothing
/// opt-in about it.
///
/// ONE call site, `render::render_to_image_in` — the single funnel every RAW's
/// pixels pass through (`render_to_file`'s RAW arm, `source_pixels`' RAW arm,
/// `render_to_image`, and through those three the CLI, the GUI and `serve`).
/// Patching the callers instead would have been the shape this file already
/// refuses for `load_image` (see that gate's doc): a per-caller copy is a
/// per-caller chance to forget.
///
/// REFUSE, never degrade (user ruling 2026-08-20, plan D2): the alternative —
/// silently developing at a reduced resolution — would hand back a deliverable
/// that is not the one asked for.
fn refuse_raw_develop_over_ceiling(path: &Path, pixels: u64) -> Result<()> {
let need = raw_develop_peak_bytes(pixels);
if allocation_over_ceiling(need) {
// Everything a person can act on: the measured basis (so the estimate
// is auditable rather than an oracle), and — because the obvious guess
// is wrong — that the concurrency flag is not the answer.
anyhow::bail!(
"{} is a {pixels}-pixel camera RAW; developing it peaks at about {need} bytes \
({pixels} px x {RAW_DEVELOP_BYTES_PER_PIXEL} B/px, measured) — at or over the \
{MAX_ALLOC}-byte ceiling this build will commit for ONE file. `--jobs 1` does not \
help: this is a single file's own peak, not a concurrency budget. This build cannot \
develop a photo this large without paging, so it refuses instead of stalling the \
machine or aborting on a failed allocation",
path.display()
);
}
Ok(())
}
/// [`refuse_raw_develop_over_ceiling`] against a decoder's DUMMY `RawImage` —
/// the metadata-only probe `render::render_to_image_in` takes before it
/// decompresses a single sensor row.
///
/// The frame rule ([`default_crop`]) stays on THIS side of the wall so the
/// ceiling is charged against the same rectangle the develop will actually
/// produce, decided in the one place that owns that rule — rather than the
/// render re-deriving "which pixels are the picture" for the purpose of a
/// budget and drifting from the answer it uses for pixels.
pub(crate) fn refuse_raw_develop_over_ceiling_for(
path: &Path,
probe: &rawler::RawImage,
) -> Result<()> {
let d = default_crop(probe).d;
refuse_raw_develop_over_ceiling(path, (d.w as u64).saturating_mul(d.h as u64))
}
/// The per-file develop peak in MB for a source whose HEADER is CHEAP to read,
/// or `None` when it is not — the admission-time half of the same accounting
/// the ceilings above enforce.
///
/// **Baked**: `image`'s reader parses a header and decodes no pixel, so the
/// real per-file peak is available for the price of an `open` +
/// `into_decoder`. `jobs.rs`' "reading each RAW's dimensions to size the
/// budget would cost a decode per photo" is simply not true here, which is why
/// the planner may consult this.
///
/// **Camera RAW**: `None`. Answering would cost `RawSource::new` — the WHOLE
/// file mapped (~120 MB for a 61 MP ARW) — per photo before the pool even
/// starts, which is exactly the cost that reasoning was about. The RAW side is
/// bounded instead by [`refuse_raw_develop_over_ceiling`] at the develop door,
/// plus the corpus constant in the planner.
///
/// Best-effort by construction: an unreadable header answers `None` rather than
/// failing, because a PLAN must not be the thing that fails a run. The photo's
/// own develop will surface the real error, loudly, where the diagnosis lives.
pub fn cheap_develop_peak_mb(path: &Path) -> Option<u64> {
if is_raw(path) {
return None;
}
// baked-by-construction: the !is_raw arm, decided one line up.
Some(baked_header_peak_bytes(path).ok()?.div_ceil(1024 * 1024))
}
/// [`develop_peak_bytes`] from a HEADER alone — no pixel decoded.
///
/// Reads through [`baked_reader`], the same raised limits the pixel path uses:
/// a probe running under the crate's 512 MB default `max_alloc` would refuse
/// to build a decoder for exactly the big exports this estimate exists for,
/// and answering "unreadable" for them would silently plan as if they were
/// small.
fn baked_header_peak_bytes(path: &Path) -> Result<u64> {
use image::ImageDecoder as _;
let mut decoder = baked_reader(path)?
.into_decoder()
.with_context(|| format!("read the header of {}", path.display()))?;
let orientation = decoder
.orientation()
.unwrap_or(image::metadata::Orientation::NoTransforms);
let (dw, dh) = decoder.dimensions();
Ok(develop_peak_bytes(
decoder.total_bytes(),
orientation,
u64::from(dw).saturating_mul(u64::from(dh)),
))
}
/// Load a baked raster (PNG/TIFF/JPEG) with a RAISED (not lifted) decoder
/// memory limit — a 60 MP export trips the image crate's default cap, but
/// `no_limits()` let a corrupt header with absurd declared dimensions drive an
/// unbounded allocation straight into OOM. 61 MP 16-bit RGBA is ~0.5 GiB;
/// 4 GiB leaves headroom without trusting arbitrary headers; guided-mask
/// refinement keeps its derived planes tile-bounded, so the two limits no
/// longer stack into an unbudgeted full-frame peak.
/// Also applies the EXIF orientation: phone/Lightroom JPEGs store rotation as
/// metadata the decoder does NOT apply — imported photos rendered sideways
/// (the RAW path already orients via the sensor metadata).
///
/// BAKED ONLY: a camera RAW is REFUSED here (see [`load_image_gated`]) — it has
/// no `image`-crate decoder, so the honest gate is a named error, not a probe
/// failure. Callers that may hold either kind of source want the one dispatch,
/// [`crate::render::source_pixels`].
// not-a-consumer-call: the gate's own declaration.
pub fn load_image(path: &Path) -> Result<DynamicImage> {
load_image_gated(path, false) // not-a-consumer-call: dispatch inside the gate
}
/// [`load_image`] for the full-frame baked DEVELOP path
/// (`render_baked_to_image`): charges each source pixel the develop chain's
/// downstream footprint ([`PIPELINE_BYTES_PER_PIXEL`]) on top of the decode
/// buffer, so the ceiling bounds the true pipeline peak — the plain gate
/// admitted an L8 source whose develop then peaked at ~25× the ceiling (L02).
/// Thumbnail consumers (GUI open, denoise/retouch/fit pre-shrink) stay on
/// [`load_image`]: they never build those planes, and charging them would
/// refuse sources they legitimately shrink.
// not-a-consumer-call: the gate's develop-charged twin.
pub fn load_image_for_develop(path: &Path) -> Result<DynamicImage> {
load_image_gated(path, true) // not-a-consumer-call: dispatch inside the gate
}
/// A baked raster's reader, opened with the RAISED (not lifted) decoder limits
/// and its format already probed.
///
/// ONE construction, because there are now TWO consumers and they must agree:
/// the pixel path ([`load_image_gated`]) and the header-only peak estimate
/// ([`baked_header_peak_bytes`]). Under the crate's own `Limits::default()`
/// the TIFF codec's `set_limits` refuses to build a decoder whose frame is
/// larger than the default 512 MB `max_alloc` — so an estimate that opened its
/// own plain reader would answer "unreadable" for precisely the 60 MP-plus
/// exports it exists to size, and the caller would plan as if they were small.
/// The 65,536-px dimension bounds and the 4 GiB allocation bound below are the
/// ones that gate's doc argues for; they live here so neither consumer can
/// drift off them.
fn baked_reader(
path: &Path,
) -> Result<image::ImageReader<std::io::BufReader<std::fs::File>>> {
let mut reader = image::ImageReader::open(path)
.with_context(|| format!("open image {}", path.display()))?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(65_536);
limits.max_image_height = Some(65_536);
limits.max_alloc = Some(MAX_ALLOC);
reader.limits(limits);
reader
.with_guessed_format()
.with_context(|| format!("probe image {}", path.display()))
}
/// THE ROUTING TABLE for the refusal below, kept in the doc rather than in the
/// error text (R24 batch 2): a RAW that arrived here belongs in
/// [`crate::render::render_to_image`] or [`crate::render::source_pixels`] (the
/// one raw-vs-baked dispatch), or in [`decode_any`] when the caller wants the
/// sensor data rather than a picture.
// not-a-consumer-call: the gate's body — where a camera RAW is refused by name.
fn load_image_gated(path: &Path, develop: bool) -> Result<DynamicImage> {
use image::ImageDecoder as _;
// The "RAW → develop engine / baked → here" dispatch, enforced at the
// GATE instead of trusting every caller to hand-copy an `is_raw` branch.
// A missed branch used to reach `ImageReader` with a .ARW and surface as
// an unrelated format/probe error (v0.22's mask-refine worker: "The image
// format could not be determined" for a photo the app had just developed
// on screen) — the class this refuses by name, wherever it happens.
if is_raw(path) {
// The sentence a USER may read. It used to end in three Rust paths
// (`render::render_to_image / render::source_pixels`, `decode::
// decode_any`), which is a developer's routing table shown in a
// desktop toast and a web error body — the two surfaces this gate
// actually reaches (the CLI routes RAWs before they arrive here). The
// routing table now lives one screen up, in this function's own doc,
// where the developer who needs it is already reading; what stays here
// is the fact and the way out, in words the person holding the photo
// can act on.
anyhow::bail!(
"{} is a camera RAW, and this step reads finished images \
(PNG/TIFF/JPEG) only — a RAW has to be developed before anything \
can read it as a picture",
path.display()
);
}
let reader = baked_reader(path)?;
let format = reader.format();
// R11: a camera RAW wearing a `.tif` extension reaches HERE, because
// `is_raw` is extension-based and a DNG (or a CR2, or a NEF) really is a
// TIFF container. The `image` crate would then decode whichever IFD it
// finds first — on a DNG that is the small embedded THUMBNAIL, so the
// photo would open, look right at a glance, and be developed at a few
// hundred pixels. That is worse than a refusal, so it IS a refusal, and it
// names the way out. Placed BEFORE `into_decoder` so the answer is this
// sentence rather than whatever the TIFF codec makes of a sensor plane.
// Costs one header parse on a baked TIFF open, next to nothing beside the
// pixel decode below.
if format == Some(image::ImageFormat::Tiff)
&& let Some(kind) = raw_in_tiff_clothing(path)
{
anyhow::bail!(
"{} is named .tif but is really a camera RAW ({kind}) — rename it to its real \
extension (e.g. .dng) so AutoShade develops the sensor instead of reading the \
thumbnail the `image` crate would find first",
path.display()
);
}
let mut decoder = reader
.into_decoder()
.with_context(|| format!("decode image {}", path.display()))?;
// into_decoder enforces the DIMENSION limits but skips decode()'s
// total_bytes reservation — without this check max_alloc never bounded
// the OUTPUT buffer (a 65536² 16-bit RGBA header passes the dimension
// gate yet decodes to ~32 GiB).
let orientation = decoder
.orientation()
.unwrap_or(image::metadata::Orientation::NoTransforms);
let decoded_bytes = decoder.total_bytes();
let decoded_color = decoder.color_type();
let (dw, dh) = decoder.dimensions();
let need = if develop {
develop_peak_bytes(
decoded_bytes,
orientation,
u64::from(dw).saturating_mul(u64::from(dh)),
)
} else {
decode_peak_bytes(decoded_bytes, orientation)
};
if allocation_over_ceiling(need) {
anyhow::bail!(
"image {} needs {need} bytes at peak (decode {decoded_bytes}, then orientation{}) \
— at or over the {MAX_ALLOC}-byte ceiling",
path.display(),
if develop { ", then the develop chain's full-frame buffers" } else { "" }
);
}
let icc_profile = decoder
.icc_profile()
.with_context(|| format!("read ICC profile {}", path.display()))?;
// image 0.25's TiffDecoder::set_limits breaks IFD tag-value reads: the
// tiff crate accounts a tag read as count × size_of::<Value>() (~32 bytes
// per profile BYTE) against `decoding_buffer_size`, which set_limits pins
// to the image's own byte size — probed empirically ("decoder limits
// exceeded", swallowed by `.ok()` inside the codec into a silent None).
// Big LR exports clear the budget; small profiled TIFFs would silently
// skip the transform — the exact assume-sRGB bug this function fixes. Ask
// a fresh, header-only decoder instead; its tag reads run under the tiff
// crate's own 1 MiB per-value default, and NO pixel is ever decoded here.
let icc_profile = match icc_profile {
Some(p) => Some(p),
None if format == Some(image::ImageFormat::Tiff) => {
// The re-probe obeys apply_icc_profile's own rule (its doc):
// unreadable colour management is a HARD error, never a silent
// assume-sRGB fall-through — "that fall-through IS the bug".
// The old .ok() pair folded a failed profile READ into "no
// profile" (L05-2). Ok(None) is the real no-profile case.
image::codecs::tiff::TiffDecoder::new(std::io::BufReader::new(
std::fs::File::open(path)
.with_context(|| format!("open image {}", path.display()))?,
))
.and_then(|mut d| d.icc_profile())
.with_context(|| format!("read the ICC profile of {}", path.display()))?
}
None => None,
};
// R10: the assume-sRGB fall-through `apply_icc_profile` refuses is only
// refusable when a profile EXISTS. An UNTAGGED file has nothing to refuse,
// and is read as sRGB — right for essentially every 8-bit JPEG (the web's
// default and what phones write), and a real risk for 16-bit, which is
// what an editor produces: Lightroom's "Edit in…" hands over ProPhoto,
// Photoshop happily saves untagged AdobeRGB. So the disclosure is aimed at
// exactly that population instead of warning on every ordinary snapshot —
// a warning that fires on the common correct case is one nobody reads.
if icc_profile.is_none()
&& matches!(
decoded_color,
image::ColorType::Rgb16 | image::ColorType::Rgba16 | image::ColorType::La16
| image::ColorType::L16
)
{
eprintln!(
"⚠ {} is 16-bit but carries no ICC profile, so its pixels are being read as sRGB. \
If it was exported as ProPhoto or Adobe RGB (Lightroom's \"Edit in…\" default is \
ProPhoto), every tone and colour decision below is computed on the wrong numbers — \
re-export it with the profile embedded",
path.display()
);
}
let mut img = DynamicImage::from_decoder(decoder)
.with_context(|| format!("decode image {}", path.display()))?;
if let Some(profile) = icc_profile {
apply_icc_profile(&mut img, &profile, path)?;
}
img.apply_orientation(orientation);
Ok(img)
}
/// Is this TIFF-container file actually a camera RAW (R11)? Names the marker
/// that gave it away, or `None` for an ordinary baked TIFF.
///
/// Header-only, and deliberately conservative: it looks for the two tags that
/// no photo editor writes into a delivery TIFF — `DNGVersion` (0xC612), which
/// only a DNG carries, and `SubIFDs` combined with a `Make` string, which is
/// the shape every TIFF-based RAW (CR2/NEF/ARW/ORF/PEF/SRW/…) uses to hang the
/// sensor plane off the root IFD. A file that will not even parse as TIFF is
/// not our problem here — the decoder below reports it.
fn raw_in_tiff_clothing(path: &Path) -> Option<&'static str> {
/// `DNGVersion`, the tag that DEFINES a DNG (rawler dispatches on it —
/// `decoders/mod.rs:919-921`).
const DNG_VERSION: u16 = 0xC612;
let file = std::fs::File::open(path).ok()?;
let mut reader = std::io::BufReader::new(file);
let tiff = GenericTiffReader::new(&mut reader, 0, 0, Some(16), &[]).ok()?;
// Same panic-shaped `root_ifd()` as `baked_exif`; a metadata probe must
// never be the thing that ends the process.
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let root = tiff.root_ifd();
if root.get_entry(DNG_VERSION).is_some() {
return Some("it carries the DNGVersion tag");
}
let has_make = root
.get_entry(TiffCommonTag::Make)
.and_then(|e| e.value.as_string().cloned())
.is_some_and(|s| !s.trim().is_empty());
if has_make && root.get_sub_ifd(TiffCommonTag::SubIFDs).is_some() {
return Some("it names a camera and hangs its sensor data off a SubIFD");
}
None
}))
.ok()
.flatten()
}
/// Decode any supported source — a camera RAW or an already-baked image. The
/// baked path (the "PNG source" mode: edit an LR/PS-denoised export) has no
/// sensor metadata, so [`Meta`] is filled with neutral defaults and the
/// histogram is computed from the pixels.
pub fn decode_any(path: &Path) -> Result<Decoded> {
decode_any_turned(path, 0)
}
/// [`decode_any`] in the frame the photographer's quarter turns produce — the
/// dispatch twin of [`decode_raw_turned`]. The baked arm turns the decoded
/// pixels directly: `load_image` has already applied their EXIF orientation,
/// so only the user's half is left, and `Meta`'s dims are re-read from the
/// turned image rather than swapped by hand.
pub fn decode_any_turned(path: &Path, quarter_turns: u8) -> Result<Decoded> {
if is_raw(path) {
return decode_raw_turned(path, quarter_turns);
}
let mut d = decode_baked(path)?;
match crate::render::quarter_turn_orientation(quarter_turns) {
rawler::Orientation::Normal | rawler::Orientation::Unknown => {}
o => {
d.preview = crate::render::oriented(d.preview, o);
d.meta.width = d.preview.width() as usize;
d.meta.height = d.preview.height() as usize;
}
}
Ok(d)
}
/// The seven capture facts [`Meta`] carries that come from EXIF rather than
/// from the sensor — ONE extraction shared by the RAW arm and the baked arm
/// (P2). Splitting them was the old bug in miniature: the RAW side grew an
/// APEX `ApertureValue` fallback and two finiteness filters that a
/// hand-written baked copy would not have had, so the same JPEG would have
/// reported a different f-number depending on which door it came through.
struct ExifFacts {
lens: Option<String>,
iso: Option<u32>,
shutter: Option<String>,
aperture: Option<f32>,
focal_length_mm: Option<f32>,
exposure_bias_ev: Option<f32>,
date_time: Option<String>,
}
fn exif_facts(exif: &rawler::exif::Exif) -> ExifFacts {
ExifFacts {
lens: exif.lens_model.clone().or_else(|| exif.lens_make.clone()),
iso: exif.iso_speed_ratings.map(u32::from).or(exif.iso_speed),
shutter: exif.exposure_time.as_ref().map(fmt_shutter),
aperture: exif
.fnumber
.as_ref()
.map(ratio)
// An INVALID FNumber (0-denominator → 0.0, or non-finite) must
// fall through to the Av fallback, not suppress it: filtering
// only at the end let f/0 shadow a perfectly valid ApertureValue.
.filter(|v| v.is_finite() && *v > 0.0)
// ApertureValue is an APEX Av, not an f-number: N = 2^(Av/2)
// (Av 4 ⇒ f/4, Av 5 ⇒ f/5.7). Feeding it raw overstated fast
// lenses in metadata + the AI prompt whenever FNumber was absent.
.or_else(|| exif.aperture_value.as_ref().map(|v| (ratio(v) / 2.0).exp2()))
// Same physical-validity rule for the fallback (huge Av → ∞;
// serde_json refuses non-finite floats — the WB-coeff rule).
.filter(|v| v.is_finite() && *v > 0.0),
focal_length_mm: exif
.focal_length
.as_ref()
.map(ratio)
.filter(|v| v.is_finite() && *v > 0.0),
exposure_bias_ev: exif.exposure_bias.as_ref().map(sratio).filter(|v| v.is_finite()),
date_time: exif.date_time_original.clone(),
}
}
/// Largest EXIF block this reader will take from a baked file. A JPEG APP1
/// segment cannot exceed 65533 payload bytes by construction (the length field
/// is 16-bit); a TIFF's header chain is walked, not buffered. 1 MiB is
/// therefore pure belt-and-braces against a hand-built file, and it is a
/// REFUSAL rather than a truncation — half an IFD parses to different numbers,
/// which is the failure mode this whole module refuses everywhere else.
const MAX_BAKED_EXIF: usize = 1024 * 1024;
/// How far into a JPEG this reader will hunt for the EXIF segment before
/// giving up. EXIF is required to be the FIRST APP segment, so anything past
/// a few hundred KB of headers is not a file that follows the spec; the cap
/// stops a crafted file from turning a metadata read into a whole-file scan.
const MAX_JPEG_HEADER_SCAN: u64 = 4 * 1024 * 1024;
/// The TIFF/EXIF block of a JPEG: the payload of the first `APP1` segment
/// whose six leading bytes are `Exif\0\0`, with that introducer stripped so
/// what comes back starts at the TIFF header (`II`/`MM`) rawler's reader
/// expects.
///
/// Markers are walked, not searched for: scanning the file for the byte pair
/// would hit `FFE1` inside compressed scan data. `D0..=D7` (restart), `01` and
/// `D8` are the standalone markers that carry no length; `DA` (start of scan)
/// ends the header region — everything after it is entropy-coded.
/// Read-only by construction — no `Seek`. Skipping a segment copies its bytes
/// to a sink rather than seeking past them, so the walk cannot depend on how
/// `Take`/`BufReader` compose their cursors; the cost is reading header bytes
/// that were about to be read anyway, bounded by [`MAX_JPEG_HEADER_SCAN`].
fn jpeg_exif_block(file: &mut std::fs::File) -> Result<Option<Vec<u8>>> {
use std::io::Read as _;
let mut r = std::io::BufReader::new(file.by_ref().take(MAX_JPEG_HEADER_SCAN));
let mut soi = [0u8; 2];
if r.read_exact(&mut soi).is_err() || soi != [0xFF, 0xD8] {
return Ok(None);
}
loop {
// Marker prefixes may be padded with any number of 0xFF fill bytes.
let mut b = [0u8; 1];
if r.read_exact(&mut b).is_err() {
return Ok(None);
}
if b[0] != 0xFF {
return Ok(None); // desynchronised — not our business to repair
}
while b[0] == 0xFF {
if r.read_exact(&mut b).is_err() {
return Ok(None);
}
}
let marker = b[0];
if marker == 0xD8 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
continue; // standalone, no length field
}
if marker == 0xDA || marker == 0xD9 {
return Ok(None); // scan data / end of image — no EXIF in this file
}
let mut len = [0u8; 2];
if r.read_exact(&mut len).is_err() {
return Ok(None);
}
let payload = u64::from(u16::from_be_bytes(len)).saturating_sub(2);
if marker != 0xE1 {
// A short copy (truncated file) is not an error here — the next
// read_exact fails and the walk ends with "no EXIF", which is the
// honest answer for a file that stops mid-header.
if std::io::copy(&mut r.by_ref().take(payload), &mut std::io::sink()).is_err() {
return Ok(None);
}
continue;
}
if payload > MAX_BAKED_EXIF as u64 {
anyhow::bail!("the EXIF segment is larger than the {MAX_BAKED_EXIF}-byte limit");
}
let mut buf = vec![0u8; payload as usize];
if r.read_exact(&mut buf).is_err() {
return Ok(None);
}
// An APP1 that is not EXIF is almost always the XMP packet
// (`http://ns.adobe.com/xap/1.0/`), which is not ours to read here.
if buf.starts_with(b"Exif\0\0") {
return Ok(Some(buf.split_off(6)));
}
}
}
/// The XMP packet of a JPEG: the `http://ns.adobe.com/xap/1.0/\0` APP1
/// segment, with any **ExtendedXMP** continuation chunks reassembled onto it.
///
/// **Why the second half is not optional** (R27, `P5-cropped-mask-frame.md`
/// §8). A JPEG APP1 segment holds at most 65533 payload bytes, and a Lightroom
/// develop block with masks routinely exceeds that: **13 exports in the user's
/// own library** are split this way, including `P30_1.jpg` — one of the
/// seven photographs P3's crop model rests on. The continuation lives in
/// further APP1 segments introduced by `http://ns.adobe.com/xmp/extension/\0`,
/// each carrying the 32-hex GUID of the extension it belongs to, the total
/// length, and its own byte offset. A reader that simply concatenates
/// `<x:xmpmeta>…</x:xmpmeta>` out of the raw bytes bridges the segment headers
/// and produces XML that is not well-formed; a reader that takes only the
/// standard segment gets a truncated document. Both were observed on this
/// library, in P5's own first pass.
///
/// The chunks are placed BY OFFSET, not by arrival order, and a chain with a
/// hole in it is `Err` rather than a quietly short document — the same rule the
/// caller applies to a packet it cannot decode. Only the GUID the standard
/// packet names (`xmpNote:HasExtendedXMP`) is accepted, so a second edit
/// generation's leftover chunks cannot splice themselves into this one.
///
/// The standard packet comes back FIRST and whole: `crs:` settings live in it,
/// and the extension carries the overflow (usually the AI-mask rasters).
fn jpeg_xmp_packet(file: &mut std::fs::File) -> Result<Option<Vec<u8>>> {
use std::io::Read as _;
const STD: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
const EXT: &[u8] = b"http://ns.adobe.com/xmp/extension/\0";
/// GUID (32) + total length (4) + offset (4).
const EXT_HEADER: usize = 40;
let mut r = std::io::BufReader::new(file.by_ref().take(MAX_JPEG_HEADER_SCAN));
let mut soi = [0u8; 2];
if r.read_exact(&mut soi).is_err() || soi != [0xFF, 0xD8] {
return Ok(None);
}
let mut standard: Option<Vec<u8>> = None;
let mut chunks: Vec<(u32, Vec<u8>)> = Vec::new();
let mut total: Option<u32> = None;
loop {
let mut b = [0u8; 1];
if r.read_exact(&mut b).is_err() {
break;
}
if b[0] != 0xFF {
break; // desynchronised — not our business to repair
}
while b[0] == 0xFF {
if r.read_exact(&mut b).is_err() {
return Ok(standard);
}
}
let marker = b[0];
if marker == 0xD8 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
continue; // standalone, no length field
}
if marker == 0xDA || marker == 0xD9 {
break; // scan data / end of image — the header region is over
}
let mut len = [0u8; 2];
if r.read_exact(&mut len).is_err() {
break;
}
let payload = u64::from(u16::from_be_bytes(len)).saturating_sub(2);
if marker != 0xE1 {
if std::io::copy(&mut r.by_ref().take(payload), &mut std::io::sink()).is_err() {
break;
}
continue;
}
let mut buf = vec![0u8; payload as usize];
if r.read_exact(&mut buf).is_err() {
break;
}
if buf.starts_with(STD) {
standard.get_or_insert_with(|| buf[STD.len()..].to_vec());
} else if buf.starts_with(EXT) && buf.len() >= EXT.len() + EXT_HEADER {
let head = &buf[EXT.len()..EXT.len() + EXT_HEADER];
let guid = head[..32].to_ascii_uppercase();
// The standard packet NAMES the extension it owns; anything else
// belongs to some other generation of this file.
let owned = standard.as_deref().is_some_and(|s| {
twoway_contains(s, &guid)
});
if !owned {
continue;
}
let len = u32::from_be_bytes([head[32], head[33], head[34], head[35]]);
let off = u32::from_be_bytes([head[36], head[37], head[38], head[39]]);
if total.is_some_and(|t| t != len) {
anyhow::bail!("its ExtendedXMP chunks disagree about the total length");
}
total = Some(len);
chunks.push((off, buf[EXT.len() + EXT_HEADER..].to_vec()));
}
}
let Some(std_packet) = standard else { return Ok(None) };
if chunks.is_empty() {
return Ok(Some(std_packet));
}
chunks.sort_by_key(|(off, _)| *off);
let mut ext = Vec::new();
for (off, body) in chunks {
if off as usize != ext.len() {