Add folder scan with format selection; validate A3 on a real library

Library setup as the user described it: pick a folder, choose which RAW
types to look for, scan recursively.

  dr-types::FormatFilter  the tick-box selection, seeing through VFS
                          placeholder suffixes so a dehydrated CR2 still
                          matches as a CR2
  dr-sync::scan           recursive walk, Depth:1 per directory, pruning
                          unchanged subtrees where the backend propagates
                          directory ETags

Verified against nextcloud.tourolle.paris (34.0.2) on a real library:

  browse root      32 entries, 98ms
  scan PhotosRaw   17,185 RAW files in 334 directories, 34.1s
                   (7,836 CR2 + 9,349 DNG)
  range read       262KB of a 21.5MB DNG in 119ms — 1.22% of the file,
                   and enough to read "Canon EOS 6D | ISO 100"

That last line is assumption A3 validated on real data. Cataloguing this
library by whole-file fetch would move roughly 370GB; the range path
moves a few MB.

Pruning is capability-gated rather than assumed: with per-entry ETags a
probe costs a request and proves nothing about children, so it is skipped
entirely. A test asserts zero probes in that case.

Still unresolved: /core/preview returns 400 for every parameter
combination tried, including on a JPEG the server reports as having a
preview. Not a request-shape bug — it fails identically bare. Recorded
rather than worked around; ARCH §6.7 already treats server previews as
opportunistic, so nothing depends on it.
This commit is contained in:
2026-08-09 12:22:31 +02:00
parent fbadf9afc8
commit c8bb08e661
29 changed files with 7193 additions and 234 deletions
+39 -83
View File
@@ -494,102 +494,58 @@ mod tests {
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
// A name paired with the edit that activates one operation. Boxed
// closures rather than a type alias: the list is read top to bottom
// as a table of what is covered.
#[allow(clippy::type_complexity)]
let cases: Vec<(&str, Box<dyn Fn(&mut EditGraph)>)> = vec![
(
"white_balance.temperature",
Box::new(|g: &mut EditGraph| {
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 60.0)
}),
),
(
"white_balance.tint",
Box::new(|g: &mut EditGraph| {
g.set_param(white_balance::ID, white_balance::TINT, -40.0)
}),
),
(
"exposure",
Box::new(|g: &mut EditGraph| g.set_param(exposure::ID, exposure::EXPOSURE, 1.5)),
),
(
"highlights",
Box::new(|g: &mut EditGraph| {
g.set_param(tone::HIGHLIGHTS_SHADOWS_ID, tone::HIGHLIGHTS, -70.0)
}),
),
(
"shadows",
Box::new(|g: &mut EditGraph| {
g.set_param(tone::HIGHLIGHTS_SHADOWS_ID, tone::SHADOWS, 70.0)
}),
),
(
"blacks",
Box::new(|g: &mut EditGraph| {
g.set_param(tone::BLACKS_WHITES_ID, tone::BLACKS, -50.0)
}),
),
(
"whites",
Box::new(|g: &mut EditGraph| {
g.set_param(tone::BLACKS_WHITES_ID, tone::WHITES, 50.0)
}),
),
(
"brilliance",
Box::new(|g: &mut EditGraph| {
g.set_param(colour::BRILLIANCE_ID, colour::BRILLIANCE, 60.0)
}),
),
(
"vibrance",
Box::new(|g: &mut EditGraph| {
g.set_param(colour::VIBRANCE_ID, colour::VIBRANCE, 60.0)
}),
),
(
"saturation",
Box::new(|g: &mut EditGraph| {
g.set_param(colour::SATURATION_ID, colour::SATURATION, 60.0)
}),
),
];
for (name, apply) in cases {
let mut g = EditGraph::default_chain();
apply(&mut g);
let shader = g.compose();
pass.render(&img, &shader, 16, 16)
.unwrap_or_else(|e| panic!("{name} generated invalid WGSL:\n{e}"));
// Cases derived from the chain itself rather than a hand-written
// list: every parameter of every operation is exercised, and adding
// an operation extends the coverage automatically instead of
// silently going untested.
let probe = EditGraph::default_chain();
for cap in probe.capabilities() {
for p in &cap.params {
let dr_pipeline::ParamKind::Scalar { min, max, .. } = p.kind else {
continue;
};
// Both extremes: a fragment can be valid at one end of its
// range and not the other.
for value in [min, max] {
let mut g = EditGraph::default_chain();
g.set_param(cap.id, p.id, value);
let shader = g.compose();
pass.render(&img, &shader, 16, 16).unwrap_or_else(|e| {
panic!(
"{}.{} at {value} generated invalid WGSL:\n{e}",
cap.id, p.id
)
});
}
}
}
}
#[test]
fn the_whole_chain_at_once_compiles() {
// Individually-valid fragments can still collide when combined —
// duplicate helpers, clashing locals, a malformed uniform block.
// duplicate helpers, clashing locals, a malformed uniform block. With
// every operation active this is the largest shader the pipeline can
// generate.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 30.0);
g.set_param(white_balance::ID, white_balance::TINT, -20.0);
g.set_param(exposure::ID, exposure::EXPOSURE, 0.8);
g.set_param(tone::HIGHLIGHTS_SHADOWS_ID, tone::HIGHLIGHTS, -60.0);
g.set_param(tone::HIGHLIGHTS_SHADOWS_ID, tone::SHADOWS, 40.0);
g.set_param(tone::BLACKS_WHITES_ID, tone::BLACKS, -25.0);
g.set_param(tone::BLACKS_WHITES_ID, tone::WHITES, 35.0);
g.set_param(colour::BRILLIANCE_ID, colour::BRILLIANCE, 45.0);
g.set_param(colour::VIBRANCE_ID, colour::VIBRANCE, 55.0);
g.set_param(colour::SATURATION_ID, colour::SATURATION, 15.0);
for cap in EditGraph::default_chain().capabilities() {
for p in &cap.params {
if let dr_pipeline::ParamKind::Scalar { max, .. } = p.kind {
g.set_param(cap.id, p.id, max * 0.6);
}
}
}
let shader = g.compose();
assert_eq!(shader.source.matches("---- ").count(), 7);
assert_eq!(
shader.source.matches("---- ").count(),
g.descriptors().len(),
"every operation should be active"
);
pass.render(&img, &shader, 32, 32)
.expect("the full chain must compile");
}