diff --git a/docs/dev/traceability.md b/docs/dev/traceability.md index bd4a21c..90ec20c 100644 --- a/docs/dev/traceability.md +++ b/docs/dev/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 392 | -| TRACES tags found | 1573 | +| Source files scanned | 414 | +| TRACES tags found | 1571 | | Requirements defined | 184 | | Requirements deferred (post-v1) | 24 | | Requirements covered | 154 | @@ -34,104 +34,104 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | ID | Tagged in | |---|---| -| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:129`](../../core/dr-sync/src/scan.rs#L129), [`core/dr-types/src/lib.rs:203`](../../core/dr-types/src/lib.rs#L203), [`core/dr-types/src/lib.rs:272`](../../core/dr-types/src/lib.rs#L272), [`core/dr-types/src/lib.rs:305`](../../core/dr-types/src/lib.rs#L305), [`platform/dr-plat/src/storage.rs:1`](../../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../../platform/dr-plat/src/storage.rs#L216), [`ui/dr-ui/src/activity.rs:1`](../../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../../ui/dr-ui/src/library.rs#L1) | +| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:129`](../../core/dr-sync/src/scan.rs#L129), [`core/dr-types/src/lib.rs:203`](../../core/dr-types/src/lib.rs#L203), [`core/dr-types/src/lib.rs:272`](../../core/dr-types/src/lib.rs#L272), [`core/dr-types/src/lib.rs:305`](../../core/dr-types/src/lib.rs#L305), [`platform/dr-plat/src/storage.rs:1`](../../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../../platform/dr-plat/src/storage.rs#L216), [`ui/dr-ui/src/activity.rs:1`](../../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1) | | FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:377`](../../core/dr-types/src/settings.rs#L377), [`platform/dr-plat/src/storage.rs:287`](../../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1559`](../../ui/dr-ui/src/lib.rs#L1559), [`ui/dr-ui/ui/import.slint:5`](../../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1033`](../../ui/dr-ui/ui/library.slint#L1033), [`ui/dr-ui/ui/library.slint:1254`](../../ui/dr-ui/ui/library.slint#L1254), [`ui/dr-ui/ui/library.slint:979`](../../ui/dr-ui/ui/library.slint#L979) | -| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1559`](../../ui/dr-ui/src/lib.rs#L1559), [`ui/dr-ui/src/library.rs:183`](../../ui/dr-ui/src/library.rs#L183), [`ui/dr-ui/src/library.rs:3396`](../../ui/dr-ui/src/library.rs#L3396), [`ui/dr-ui/ui/import.slint:5`](../../ui/dr-ui/ui/import.slint#L5) | +| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1559`](../../ui/dr-ui/src/lib.rs#L1559), [`ui/dr-ui/src/library/filters.rs:12`](../../ui/dr-ui/src/library/filters.rs#L12), [`ui/dr-ui/src/library/thumbnails_gen.rs:518`](../../ui/dr-ui/src/library/thumbnails_gen.rs#L518), [`ui/dr-ui/ui/import.slint:5`](../../ui/dr-ui/ui/import.slint#L5) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:137`](../../core/dr-pipeline/src/sidecar.rs#L137) | -| FR-CAT-13 | [`core/dr-catalog/src/rating.rs:278`](../../core/dr-catalog/src/rating.rs#L278), [`core/dr-catalog/src/schema.rs:836`](../../core/dr-catalog/src/schema.rs#L836), [`core/dr-sync/src/scan.rs:40`](../../core/dr-sync/src/scan.rs#L40), [`core/dr-sync/src/scan.rs:944`](../../core/dr-sync/src/scan.rs#L944), [`core/dr-types/src/settings.rs:92`](../../core/dr-types/src/settings.rs#L92), [`core/dr-xmp/src/lib.rs:1`](../../core/dr-xmp/src/lib.rs#L1), [`core/dr-xmp/src/lib.rs:331`](../../core/dr-xmp/src/lib.rs#L331), [`core/dr-xmp/src/lib.rs:442`](../../core/dr-xmp/src/lib.rs#L442), [`core/dr-xmp/src/lib.rs:535`](../../core/dr-xmp/src/lib.rs#L535), [`core/dr-xmp/src/lib.rs:733`](../../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../../core/dr-xmp/src/lib.rs#L746), [`core/dr-xmp/src/read.rs:1`](../../core/dr-xmp/src/read.rs#L1), [`core/dr-xmp/src/write.rs:1`](../../core/dr-xmp/src/write.rs#L1), [`core/dr-xmp/tests/round_trip.rs:1`](../../core/dr-xmp/tests/round_trip.rs#L1), [`ui/dr-ui/src/library.rs:1564`](../../ui/dr-ui/src/library.rs#L1564), [`ui/dr-ui/src/library.rs:1627`](../../ui/dr-ui/src/library.rs#L1627), [`ui/dr-ui/src/library.rs:1643`](../../ui/dr-ui/src/library.rs#L1643), [`ui/dr-ui/src/library.rs:1750`](../../ui/dr-ui/src/library.rs#L1750), [`ui/dr-ui/src/library_ui.rs:1322`](../../ui/dr-ui/src/library_ui.rs#L1322), [`ui/dr-ui/src/library_ui.rs:301`](../../ui/dr-ui/src/library_ui.rs#L301), [`ui/dr-ui/src/library_ui.rs:3427`](../../ui/dr-ui/src/library_ui.rs#L3427), [`ui/dr-ui/src/library_ui.rs:3483`](../../ui/dr-ui/src/library_ui.rs#L3483), [`ui/dr-ui/src/library_ui.rs:3581`](../../ui/dr-ui/src/library_ui.rs#L3581), [`ui/dr-ui/src/library_ui.rs:392`](../../ui/dr-ui/src/library_ui.rs#L392), [`ui/dr-ui/src/library_ui.rs:537`](../../ui/dr-ui/src/library_ui.rs#L537), [`ui/dr-ui/src/library_ui.rs:5766`](../../ui/dr-ui/src/library_ui.rs#L5766), [`ui/dr-ui/src/settings_ui.rs:540`](../../ui/dr-ui/src/settings_ui.rs#L540), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:250`](../../ui/dr-ui/src/xmp_sync.rs#L250), [`ui/dr-ui/src/xmp_sync.rs:287`](../../ui/dr-ui/src/xmp_sync.rs#L287), [`ui/dr-ui/src/xmp_sync.rs:324`](../../ui/dr-ui/src/xmp_sync.rs#L324), [`ui/dr-ui/ui/app.slint:900`](../../ui/dr-ui/ui/app.slint#L900), [`ui/dr-ui/ui/settings.slint:1093`](../../ui/dr-ui/ui/settings.slint#L1093), [`ui/dr-ui/ui/settings.slint:199`](../../ui/dr-ui/ui/settings.slint#L199) | -| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:1210`](../../core/dr-catalog/src/schema.rs#L1210), [`core/dr-catalog/src/trash.rs:1`](../../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:522`](../../core/dr-sync-nextcloud/src/lib.rs#L522), [`core/dr-sync/src/lib.rs:135`](../../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:618`](../../core/dr-sync/src/scan.rs#L618), [`core/dr-sync/src/scan.rs:93`](../../core/dr-sync/src/scan.rs#L93), [`core/dr-thumbs/src/lib.rs:393`](../../core/dr-thumbs/src/lib.rs#L393), [`core/dr-types/src/place.rs:91`](../../core/dr-types/src/place.rs#L91), [`ui/dr-ui/src/collections_ui.rs:1435`](../../ui/dr-ui/src/collections_ui.rs#L1435), [`ui/dr-ui/src/collections_ui.rs:2341`](../../ui/dr-ui/src/collections_ui.rs#L2341), [`ui/dr-ui/src/library.rs:183`](../../ui/dr-ui/src/library.rs#L183), [`ui/dr-ui/src/library.rs:200`](../../ui/dr-ui/src/library.rs#L200), [`ui/dr-ui/src/library.rs:249`](../../ui/dr-ui/src/library.rs#L249), [`ui/dr-ui/src/library.rs:4592`](../../ui/dr-ui/src/library.rs#L4592), [`ui/dr-ui/src/library.rs:4626`](../../ui/dr-ui/src/library.rs#L4626), [`ui/dr-ui/src/library_ui.rs:238`](../../ui/dr-ui/src/library_ui.rs#L238), [`ui/dr-ui/src/library_ui.rs:914`](../../ui/dr-ui/src/library_ui.rs#L914), [`ui/dr-ui/src/trash.rs:1`](../../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:745`](../../ui/dr-ui/ui/collections.slint#L745) | +| FR-CAT-13 | [`core/dr-catalog/src/rating.rs:278`](../../core/dr-catalog/src/rating.rs#L278), [`core/dr-catalog/src/schema.rs:836`](../../core/dr-catalog/src/schema.rs#L836), [`core/dr-sync/src/scan.rs:40`](../../core/dr-sync/src/scan.rs#L40), [`core/dr-sync/src/scan.rs:944`](../../core/dr-sync/src/scan.rs#L944), [`core/dr-types/src/settings.rs:92`](../../core/dr-types/src/settings.rs#L92), [`core/dr-xmp/src/lib.rs:1`](../../core/dr-xmp/src/lib.rs#L1), [`core/dr-xmp/src/lib.rs:331`](../../core/dr-xmp/src/lib.rs#L331), [`core/dr-xmp/src/lib.rs:442`](../../core/dr-xmp/src/lib.rs#L442), [`core/dr-xmp/src/lib.rs:535`](../../core/dr-xmp/src/lib.rs#L535), [`core/dr-xmp/src/lib.rs:733`](../../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../../core/dr-xmp/src/lib.rs#L746), [`core/dr-xmp/src/read.rs:1`](../../core/dr-xmp/src/read.rs#L1), [`core/dr-xmp/src/write.rs:1`](../../core/dr-xmp/src/write.rs#L1), [`core/dr-xmp/tests/round_trip.rs:1`](../../core/dr-xmp/tests/round_trip.rs#L1), [`ui/dr-ui/src/library/scan.rs:335`](../../ui/dr-ui/src/library/scan.rs#L335), [`ui/dr-ui/src/library/xmp.rs:132`](../../ui/dr-ui/src/library/xmp.rs#L132), [`ui/dr-ui/src/library/xmp.rs:25`](../../ui/dr-ui/src/library/xmp.rs#L25), [`ui/dr-ui/src/library/xmp.rs:9`](../../ui/dr-ui/src/library/xmp.rs#L9), [`ui/dr-ui/src/library_ui.rs:1322`](../../ui/dr-ui/src/library_ui.rs#L1322), [`ui/dr-ui/src/library_ui.rs:301`](../../ui/dr-ui/src/library_ui.rs#L301), [`ui/dr-ui/src/library_ui.rs:3427`](../../ui/dr-ui/src/library_ui.rs#L3427), [`ui/dr-ui/src/library_ui.rs:3483`](../../ui/dr-ui/src/library_ui.rs#L3483), [`ui/dr-ui/src/library_ui.rs:3581`](../../ui/dr-ui/src/library_ui.rs#L3581), [`ui/dr-ui/src/library_ui.rs:392`](../../ui/dr-ui/src/library_ui.rs#L392), [`ui/dr-ui/src/library_ui.rs:537`](../../ui/dr-ui/src/library_ui.rs#L537), [`ui/dr-ui/src/library_ui.rs:5766`](../../ui/dr-ui/src/library_ui.rs#L5766), [`ui/dr-ui/src/settings_ui.rs:540`](../../ui/dr-ui/src/settings_ui.rs#L540), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:250`](../../ui/dr-ui/src/xmp_sync.rs#L250), [`ui/dr-ui/src/xmp_sync.rs:287`](../../ui/dr-ui/src/xmp_sync.rs#L287), [`ui/dr-ui/src/xmp_sync.rs:324`](../../ui/dr-ui/src/xmp_sync.rs#L324), [`ui/dr-ui/ui/app.slint:900`](../../ui/dr-ui/ui/app.slint#L900), [`ui/dr-ui/ui/settings.slint:1093`](../../ui/dr-ui/ui/settings.slint#L1093), [`ui/dr-ui/ui/settings.slint:199`](../../ui/dr-ui/ui/settings.slint#L199) | +| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:1210`](../../core/dr-catalog/src/schema.rs#L1210), [`core/dr-catalog/src/trash.rs:1`](../../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:522`](../../core/dr-sync-nextcloud/src/lib.rs#L522), [`core/dr-sync/src/lib.rs:135`](../../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:618`](../../core/dr-sync/src/scan.rs#L618), [`core/dr-sync/src/scan.rs:93`](../../core/dr-sync/src/scan.rs#L93), [`core/dr-thumbs/src/lib.rs:393`](../../core/dr-thumbs/src/lib.rs#L393), [`core/dr-types/src/place.rs:91`](../../core/dr-types/src/place.rs#L91), [`ui/dr-ui/src/collections_ui.rs:1435`](../../ui/dr-ui/src/collections_ui.rs#L1435), [`ui/dr-ui/src/collections_ui.rs:2341`](../../ui/dr-ui/src/collections_ui.rs#L2341), [`ui/dr-ui/src/library/cells.rs:158`](../../ui/dr-ui/src/library/cells.rs#L158), [`ui/dr-ui/src/library/cells.rs:192`](../../ui/dr-ui/src/library/cells.rs#L192), [`ui/dr-ui/src/library/filters.rs:12`](../../ui/dr-ui/src/library/filters.rs#L12), [`ui/dr-ui/src/library/filters.rs:29`](../../ui/dr-ui/src/library/filters.rs#L29), [`ui/dr-ui/src/library/filters.rs:59`](../../ui/dr-ui/src/library/filters.rs#L59), [`ui/dr-ui/src/library_ui.rs:238`](../../ui/dr-ui/src/library_ui.rs#L238), [`ui/dr-ui/src/library_ui.rs:914`](../../ui/dr-ui/src/library_ui.rs#L914), [`ui/dr-ui/src/trash.rs:1`](../../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:745`](../../ui/dr-ui/ui/collections.slint#L745) | | FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:56`](../../core/dr-types/src/lib.rs#L56), [`platform/dr-plat/src/storage.rs:1`](../../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../../platform/dr-plat/src/storage.rs#L46) | | FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../../core/dr-catalog/src/schema.rs#L1) | -| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:105`](../../core/dr-sync/src/scan.rs#L105), [`core/dr-thumbs/src/codec.rs:1`](../../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:463`](../../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:3940`](../../ui/dr-ui/src/library.rs#L3940), [`ui/dr-ui/src/library_ui.rs:177`](../../ui/dr-ui/src/library_ui.rs#L177), [`ui/dr-ui/src/library_ui.rs:4515`](../../ui/dr-ui/src/library_ui.rs#L4515), [`ui/dr-ui/src/library_ui.rs:6027`](../../ui/dr-ui/src/library_ui.rs#L6027), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/src/repairs.rs:687`](../../ui/dr-ui/src/repairs.rs#L687), [`ui/dr-ui/ui/app.slint:387`](../../ui/dr-ui/ui/app.slint#L387), [`ui/dr-ui/ui/settings.slint:435`](../../ui/dr-ui/ui/settings.slint#L435), [`ui/dr-ui/ui/settings.slint:73`](../../ui/dr-ui/ui/settings.slint#L73) | -| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:564`](../../core/dr-catalog/src/schema.rs#L564), [`ui/dr-ui/src/library.rs:1`](../../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:230`](../../ui/dr-ui/src/library.rs#L230), [`ui/dr-ui/src/library_ui.rs:1`](../../ui/dr-ui/src/library_ui.rs#L1) | -| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1125`](../../core/dr-catalog/src/schema.rs#L1125), [`core/dr-catalog/src/schema.rs:1682`](../../core/dr-catalog/src/schema.rs#L1682), [`core/dr-decode/src/lib.rs:301`](../../core/dr-decode/src/lib.rs#L301), [`core/dr-decode/src/lib.rs:307`](../../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/lib.rs:317`](../../core/dr-decode/src/lib.rs#L317), [`core/dr-decode/src/lib.rs:444`](../../core/dr-decode/src/lib.rs#L444), [`core/dr-export/src/dng.rs:308`](../../core/dr-export/src/dng.rs#L308), [`core/dr-pipeline/src/sidecar.rs:171`](../../core/dr-pipeline/src/sidecar.rs#L171), [`ui/dr-ui/src/collections_ui.rs:1731`](../../ui/dr-ui/src/collections_ui.rs#L1731), [`ui/dr-ui/src/collections_ui.rs:1837`](../../ui/dr-ui/src/collections_ui.rs#L1837), [`ui/dr-ui/src/collections_ui.rs:1886`](../../ui/dr-ui/src/collections_ui.rs#L1886), [`ui/dr-ui/src/collections_ui.rs:275`](../../ui/dr-ui/src/collections_ui.rs#L275), [`ui/dr-ui/src/collections_ui.rs:484`](../../ui/dr-ui/src/collections_ui.rs#L484), [`ui/dr-ui/src/collections_ui.rs:87`](../../ui/dr-ui/src/collections_ui.rs#L87), [`ui/dr-ui/src/library.rs:4640`](../../ui/dr-ui/src/library.rs#L4640), [`ui/dr-ui/src/library_ui.rs:789`](../../ui/dr-ui/src/library_ui.rs#L789), [`ui/dr-ui/src/library_ui.rs:8167`](../../ui/dr-ui/src/library_ui.rs#L8167), [`ui/dr-ui/src/library_ui.rs:8178`](../../ui/dr-ui/src/library_ui.rs#L8178), [`ui/dr-ui/src/library_ui.rs:8191`](../../ui/dr-ui/src/library_ui.rs#L8191), [`ui/dr-ui/src/library_ui.rs:8206`](../../ui/dr-ui/src/library_ui.rs#L8206), [`ui/dr-ui/src/library_ui.rs:8215`](../../ui/dr-ui/src/library_ui.rs#L8215), [`ui/dr-ui/ui/app.slint:308`](../../ui/dr-ui/ui/app.slint#L308), [`ui/dr-ui/ui/app.slint:575`](../../ui/dr-ui/ui/app.slint#L575), [`ui/dr-ui/ui/library.slint:1360`](../../ui/dr-ui/ui/library.slint#L1360), [`ui/dr-ui/ui/library.slint:1384`](../../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1437`](../../ui/dr-ui/ui/library.slint#L1437), [`ui/dr-ui/ui/library.slint:19`](../../ui/dr-ui/ui/library.slint#L19), [`ui/dr-ui/ui/library.slint:4246`](../../ui/dr-ui/ui/library.slint#L4246) | -| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1125`](../../core/dr-catalog/src/schema.rs#L1125), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`core/dr-types/src/selector.rs:1`](../../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:64`](../../core/dr-types/src/settings.rs#L64), [`core/dr-types/src/time.rs:67`](../../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:254`](../../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:5116`](../../ui/dr-ui/src/library.rs#L5116), [`ui/dr-ui/src/library_ui.rs:396`](../../ui/dr-ui/src/library_ui.rs#L396), [`ui/dr-ui/src/library_ui.rs:505`](../../ui/dr-ui/src/library_ui.rs#L505), [`ui/dr-ui/src/library_ui.rs:6766`](../../ui/dr-ui/src/library_ui.rs#L6766), [`ui/dr-ui/src/library_ui.rs:6819`](../../ui/dr-ui/src/library_ui.rs#L6819), [`ui/dr-ui/src/library_ui.rs:8307`](../../ui/dr-ui/src/library_ui.rs#L8307), [`ui/dr-ui/ui/app.slint:320`](../../ui/dr-ui/ui/app.slint#L320), [`ui/dr-ui/ui/app.slint:575`](../../ui/dr-ui/ui/app.slint#L575), [`ui/dr-ui/ui/app.slint:896`](../../ui/dr-ui/ui/app.slint#L896), [`ui/dr-ui/ui/library.slint:111`](../../ui/dr-ui/ui/library.slint#L111), [`ui/dr-ui/ui/library.slint:1552`](../../ui/dr-ui/ui/library.slint#L1552), [`ui/dr-ui/ui/library.slint:4246`](../../ui/dr-ui/ui/library.slint#L4246), [`ui/dr-ui/ui/settings.slint:191`](../../ui/dr-ui/ui/settings.slint#L191) | -| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1117`](../../ui/dr-ui/src/collections_ui.rs#L1117), [`ui/dr-ui/src/collections_ui.rs:117`](../../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:1225`](../../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1704`](../../ui/dr-ui/src/collections_ui.rs#L1704), [`ui/dr-ui/src/collections_ui.rs:1750`](../../ui/dr-ui/src/collections_ui.rs#L1750), [`ui/dr-ui/src/collections_ui.rs:1`](../../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2000`](../../ui/dr-ui/src/collections_ui.rs#L2000), [`ui/dr-ui/src/collections_ui.rs:2065`](../../ui/dr-ui/src/collections_ui.rs#L2065), [`ui/dr-ui/src/collections_ui.rs:2125`](../../ui/dr-ui/src/collections_ui.rs#L2125), [`ui/dr-ui/src/collections_ui.rs:2701`](../../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:2755`](../../ui/dr-ui/src/collections_ui.rs#L2755), [`ui/dr-ui/src/collections_ui.rs:2801`](../../ui/dr-ui/src/collections_ui.rs#L2801), [`ui/dr-ui/src/collections_ui.rs:2903`](../../ui/dr-ui/src/collections_ui.rs#L2903), [`ui/dr-ui/src/collections_ui.rs:2959`](../../ui/dr-ui/src/collections_ui.rs#L2959), [`ui/dr-ui/src/collections_ui.rs:3120`](../../ui/dr-ui/src/collections_ui.rs#L3120), [`ui/dr-ui/src/collections_ui.rs:3142`](../../ui/dr-ui/src/collections_ui.rs#L3142), [`ui/dr-ui/src/collections_ui.rs:3208`](../../ui/dr-ui/src/collections_ui.rs#L3208), [`ui/dr-ui/src/collections_ui.rs:329`](../../ui/dr-ui/src/collections_ui.rs#L329), [`ui/dr-ui/src/collections_ui.rs:3328`](../../ui/dr-ui/src/collections_ui.rs#L3328), [`ui/dr-ui/src/collections_ui.rs:339`](../../ui/dr-ui/src/collections_ui.rs#L339), [`ui/dr-ui/src/collections_ui.rs:362`](../../ui/dr-ui/src/collections_ui.rs#L362), [`ui/dr-ui/src/collections_ui.rs:3633`](../../ui/dr-ui/src/collections_ui.rs#L3633), [`ui/dr-ui/src/collections_ui.rs:383`](../../ui/dr-ui/src/collections_ui.rs#L383), [`ui/dr-ui/src/collections_ui.rs:514`](../../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:718`](../../ui/dr-ui/src/collections_ui.rs#L718), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:4921`](../../ui/dr-ui/src/library.rs#L4921), [`ui/dr-ui/src/library.rs:4947`](../../ui/dr-ui/src/library.rs#L4947), [`ui/dr-ui/src/library.rs:5002`](../../ui/dr-ui/src/library.rs#L5002), [`ui/dr-ui/src/library_ui.rs:2774`](../../ui/dr-ui/src/library_ui.rs#L2774), [`ui/dr-ui/src/library_ui.rs:4369`](../../ui/dr-ui/src/library_ui.rs#L4369), [`ui/dr-ui/src/library_ui.rs:5128`](../../ui/dr-ui/src/library_ui.rs#L5128), [`ui/dr-ui/ui/app.slint:311`](../../ui/dr-ui/ui/app.slint#L311), [`ui/dr-ui/ui/app.slint:3213`](../../ui/dr-ui/ui/app.slint#L3213), [`ui/dr-ui/ui/app.slint:570`](../../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/app.slint:599`](../../ui/dr-ui/ui/app.slint#L599), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:587`](../../ui/dr-ui/ui/collections.slint#L587), [`ui/dr-ui/ui/library.slint:1327`](../../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:2942`](../../ui/dr-ui/ui/library.slint#L2942), [`ui/dr-ui/ui/library.slint:4224`](../../ui/dr-ui/ui/library.slint#L4224), [`ui/dr-ui/ui/library.slint:4311`](../../ui/dr-ui/ui/library.slint#L4311), [`ui/dr-ui/ui/library.slint:998`](../../ui/dr-ui/ui/library.slint#L998) | -| FR-CAT-8 | [`core/dr-catalog/src/schema.rs:740`](../../core/dr-catalog/src/schema.rs#L740), [`core/dr-pipeline/src/coverage.rs:1`](../../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/graph.rs:587`](../../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/graph.rs:640`](../../core/dr-pipeline/src/graph.rs#L640), [`core/dr-pipeline/src/ops/curve.rs:137`](../../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1400`](../../core/dr-pipeline/src/sidecar.rs#L1400), [`core/dr-pipeline/src/sidecar.rs:2232`](../../core/dr-pipeline/src/sidecar.rs#L2232), [`core/dr-pipeline/src/sidecar.rs:96`](../../core/dr-pipeline/src/sidecar.rs#L96), [`core/dr-pipeline/src/state.rs:1`](../../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../../core/dr-pipeline/tests/tone_curve.rs#L34), [`core/dr-sync/src/scan.rs:31`](../../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../../core/dr-sync/src/scan.rs#L926), [`ui/dr-ui/src/develop.rs:2155`](../../ui/dr-ui/src/develop.rs#L2155), [`ui/dr-ui/src/develop.rs:5463`](../../ui/dr-ui/src/develop.rs#L5463), [`ui/dr-ui/src/develop.rs:5492`](../../ui/dr-ui/src/develop.rs#L5492), [`ui/dr-ui/src/develop.rs:7057`](../../ui/dr-ui/src/develop.rs#L7057), [`ui/dr-ui/src/develop.rs:7111`](../../ui/dr-ui/src/develop.rs#L7111), [`ui/dr-ui/src/develop.rs:7213`](../../ui/dr-ui/src/develop.rs#L7213), [`ui/dr-ui/src/export.rs:812`](../../ui/dr-ui/src/export.rs#L812), [`ui/dr-ui/src/lib.rs:1330`](../../ui/dr-ui/src/lib.rs#L1330), [`ui/dr-ui/src/lib.rs:1888`](../../ui/dr-ui/src/lib.rs#L1888), [`ui/dr-ui/src/lib.rs:2457`](../../ui/dr-ui/src/lib.rs#L2457), [`ui/dr-ui/src/lib.rs:2604`](../../ui/dr-ui/src/lib.rs#L2604), [`ui/dr-ui/src/lib.rs:757`](../../ui/dr-ui/src/lib.rs#L757), [`ui/dr-ui/src/library.rs:1455`](../../ui/dr-ui/src/library.rs#L1455), [`ui/dr-ui/src/library.rs:1490`](../../ui/dr-ui/src/library.rs#L1490), [`ui/dr-ui/src/library.rs:2491`](../../ui/dr-ui/src/library.rs#L2491), [`ui/dr-ui/src/library.rs:564`](../../ui/dr-ui/src/library.rs#L564), [`ui/dr-ui/src/library.rs:616`](../../ui/dr-ui/src/library.rs#L616), [`ui/dr-ui/src/library.rs:653`](../../ui/dr-ui/src/library.rs#L653), [`ui/dr-ui/src/library.rs:7386`](../../ui/dr-ui/src/library.rs#L7386), [`ui/dr-ui/src/library.rs:73`](../../ui/dr-ui/src/library.rs#L73), [`ui/dr-ui/src/library.rs:926`](../../ui/dr-ui/src/library.rs#L926), [`ui/dr-ui/src/library_ui.rs:1339`](../../ui/dr-ui/src/library_ui.rs#L1339), [`ui/dr-ui/src/library_ui.rs:6350`](../../ui/dr-ui/src/library_ui.rs#L6350), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1) | -| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:1182`](../../core/dr-catalog/src/schema.rs#L1182), [`core/dr-catalog/src/walk.rs:1022`](../../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:162`](../../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:728`](../../core/dr-catalog/src/walk.rs#L728), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/error.rs:113`](../../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/reachability.rs:1`](../../core/dr-sync/src/reachability.rs#L1), [`core/dr-sync/src/scan.rs:211`](../../core/dr-sync/src/scan.rs#L211), [`core/dr-sync/src/scan.rs:519`](../../core/dr-sync/src/scan.rs#L519), [`core/dr-types/src/lib.rs:122`](../../core/dr-types/src/lib.rs#L122), [`ui/dr-ui/src/develop.rs:4752`](../../ui/dr-ui/src/develop.rs#L4752), [`ui/dr-ui/src/develop.rs:4795`](../../ui/dr-ui/src/develop.rs#L4795), [`ui/dr-ui/src/library.rs:1355`](../../ui/dr-ui/src/library.rs#L1355), [`ui/dr-ui/src/library.rs:1408`](../../ui/dr-ui/src/library.rs#L1408), [`ui/dr-ui/src/library.rs:1439`](../../ui/dr-ui/src/library.rs#L1439), [`ui/dr-ui/src/library.rs:1490`](../../ui/dr-ui/src/library.rs#L1490), [`ui/dr-ui/src/library.rs:169`](../../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2019`](../../ui/dr-ui/src/library.rs#L2019), [`ui/dr-ui/src/library.rs:2471`](../../ui/dr-ui/src/library.rs#L2471), [`ui/dr-ui/src/library.rs:2527`](../../ui/dr-ui/src/library.rs#L2527), [`ui/dr-ui/src/library.rs:274`](../../ui/dr-ui/src/library.rs#L274), [`ui/dr-ui/src/library.rs:5224`](../../ui/dr-ui/src/library.rs#L5224), [`ui/dr-ui/src/library.rs:653`](../../ui/dr-ui/src/library.rs#L653), [`ui/dr-ui/src/library.rs:910`](../../ui/dr-ui/src/library.rs#L910), [`ui/dr-ui/src/library.rs:926`](../../ui/dr-ui/src/library.rs#L926), [`ui/dr-ui/src/library.rs:980`](../../ui/dr-ui/src/library.rs#L980), [`ui/dr-ui/src/library_ui.rs:1406`](../../ui/dr-ui/src/library_ui.rs#L1406), [`ui/dr-ui/src/library_ui.rs:1997`](../../ui/dr-ui/src/library_ui.rs#L1997), [`ui/dr-ui/src/library_ui.rs:2041`](../../ui/dr-ui/src/library_ui.rs#L2041), [`ui/dr-ui/src/library_ui.rs:2057`](../../ui/dr-ui/src/library_ui.rs#L2057), [`ui/dr-ui/src/library_ui.rs:2151`](../../ui/dr-ui/src/library_ui.rs#L2151), [`ui/dr-ui/src/library_ui.rs:284`](../../ui/dr-ui/src/library_ui.rs#L284), [`ui/dr-ui/src/library_ui.rs:2993`](../../ui/dr-ui/src/library_ui.rs#L2993), [`ui/dr-ui/src/library_ui.rs:320`](../../ui/dr-ui/src/library_ui.rs#L320), [`ui/dr-ui/src/library_ui.rs:328`](../../ui/dr-ui/src/library_ui.rs#L328), [`ui/dr-ui/src/library_ui.rs:3616`](../../ui/dr-ui/src/library_ui.rs#L3616), [`ui/dr-ui/src/library_ui.rs:3838`](../../ui/dr-ui/src/library_ui.rs#L3838), [`ui/dr-ui/src/library_ui.rs:4116`](../../ui/dr-ui/src/library_ui.rs#L4116), [`ui/dr-ui/src/library_ui.rs:4232`](../../ui/dr-ui/src/library_ui.rs#L4232), [`ui/dr-ui/src/library_ui.rs:4420`](../../ui/dr-ui/src/library_ui.rs#L4420), [`ui/dr-ui/src/library_ui.rs:4542`](../../ui/dr-ui/src/library_ui.rs#L4542), [`ui/dr-ui/src/library_ui.rs:573`](../../ui/dr-ui/src/library_ui.rs#L573), [`ui/dr-ui/src/library_ui.rs:662`](../../ui/dr-ui/src/library_ui.rs#L662), [`ui/dr-ui/src/library_ui.rs:6864`](../../ui/dr-ui/src/library_ui.rs#L6864), [`ui/dr-ui/src/library_ui.rs:6946`](../../ui/dr-ui/src/library_ui.rs#L6946), [`ui/dr-ui/src/presets.rs:486`](../../ui/dr-ui/src/presets.rs#L486), [`ui/dr-ui/src/presets.rs:498`](../../ui/dr-ui/src/presets.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | +| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:105`](../../core/dr-sync/src/scan.rs#L105), [`core/dr-thumbs/src/codec.rs:1`](../../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:463`](../../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library/sweep.rs:557`](../../ui/dr-ui/src/library/sweep.rs#L557), [`ui/dr-ui/src/library_ui.rs:177`](../../ui/dr-ui/src/library_ui.rs#L177), [`ui/dr-ui/src/library_ui.rs:4515`](../../ui/dr-ui/src/library_ui.rs#L4515), [`ui/dr-ui/src/library_ui.rs:6027`](../../ui/dr-ui/src/library_ui.rs#L6027), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/src/repairs.rs:687`](../../ui/dr-ui/src/repairs.rs#L687), [`ui/dr-ui/ui/app.slint:387`](../../ui/dr-ui/ui/app.slint#L387), [`ui/dr-ui/ui/settings.slint:435`](../../ui/dr-ui/ui/settings.slint#L435), [`ui/dr-ui/ui/settings.slint:73`](../../ui/dr-ui/ui/settings.slint#L73) | +| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:564`](../../core/dr-catalog/src/schema.rs#L564), [`ui/dr-ui/src/library/filters.rs:39`](../../ui/dr-ui/src/library/filters.rs#L39), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../../ui/dr-ui/src/library_ui.rs#L1) | +| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1125`](../../core/dr-catalog/src/schema.rs#L1125), [`core/dr-catalog/src/schema.rs:1682`](../../core/dr-catalog/src/schema.rs#L1682), [`core/dr-decode/src/lib.rs:301`](../../core/dr-decode/src/lib.rs#L301), [`core/dr-decode/src/lib.rs:307`](../../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/lib.rs:317`](../../core/dr-decode/src/lib.rs#L317), [`core/dr-decode/src/lib.rs:444`](../../core/dr-decode/src/lib.rs#L444), [`core/dr-export/src/dng.rs:308`](../../core/dr-export/src/dng.rs#L308), [`core/dr-pipeline/src/sidecar.rs:171`](../../core/dr-pipeline/src/sidecar.rs#L171), [`ui/dr-ui/src/collections_ui.rs:1731`](../../ui/dr-ui/src/collections_ui.rs#L1731), [`ui/dr-ui/src/collections_ui.rs:1837`](../../ui/dr-ui/src/collections_ui.rs#L1837), [`ui/dr-ui/src/collections_ui.rs:1886`](../../ui/dr-ui/src/collections_ui.rs#L1886), [`ui/dr-ui/src/collections_ui.rs:275`](../../ui/dr-ui/src/collections_ui.rs#L275), [`ui/dr-ui/src/collections_ui.rs:484`](../../ui/dr-ui/src/collections_ui.rs#L484), [`ui/dr-ui/src/collections_ui.rs:87`](../../ui/dr-ui/src/collections_ui.rs#L87), [`ui/dr-ui/src/library/cells.rs:206`](../../ui/dr-ui/src/library/cells.rs#L206), [`ui/dr-ui/src/library_ui.rs:789`](../../ui/dr-ui/src/library_ui.rs#L789), [`ui/dr-ui/src/library_ui.rs:8167`](../../ui/dr-ui/src/library_ui.rs#L8167), [`ui/dr-ui/src/library_ui.rs:8178`](../../ui/dr-ui/src/library_ui.rs#L8178), [`ui/dr-ui/src/library_ui.rs:8191`](../../ui/dr-ui/src/library_ui.rs#L8191), [`ui/dr-ui/src/library_ui.rs:8206`](../../ui/dr-ui/src/library_ui.rs#L8206), [`ui/dr-ui/src/library_ui.rs:8215`](../../ui/dr-ui/src/library_ui.rs#L8215), [`ui/dr-ui/ui/app.slint:308`](../../ui/dr-ui/ui/app.slint#L308), [`ui/dr-ui/ui/app.slint:575`](../../ui/dr-ui/ui/app.slint#L575), [`ui/dr-ui/ui/library.slint:1360`](../../ui/dr-ui/ui/library.slint#L1360), [`ui/dr-ui/ui/library.slint:1384`](../../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1437`](../../ui/dr-ui/ui/library.slint#L1437), [`ui/dr-ui/ui/library.slint:19`](../../ui/dr-ui/ui/library.slint#L19), [`ui/dr-ui/ui/library.slint:4246`](../../ui/dr-ui/ui/library.slint#L4246) | +| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1125`](../../core/dr-catalog/src/schema.rs#L1125), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`core/dr-types/src/selector.rs:1`](../../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:64`](../../core/dr-types/src/settings.rs#L64), [`core/dr-types/src/time.rs:67`](../../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library/filters.rs:64`](../../ui/dr-ui/src/library/filters.rs#L64), [`ui/dr-ui/src/library/spans.rs:253`](../../ui/dr-ui/src/library/spans.rs#L253), [`ui/dr-ui/src/library_ui.rs:396`](../../ui/dr-ui/src/library_ui.rs#L396), [`ui/dr-ui/src/library_ui.rs:505`](../../ui/dr-ui/src/library_ui.rs#L505), [`ui/dr-ui/src/library_ui.rs:6766`](../../ui/dr-ui/src/library_ui.rs#L6766), [`ui/dr-ui/src/library_ui.rs:6819`](../../ui/dr-ui/src/library_ui.rs#L6819), [`ui/dr-ui/src/library_ui.rs:8307`](../../ui/dr-ui/src/library_ui.rs#L8307), [`ui/dr-ui/ui/app.slint:320`](../../ui/dr-ui/ui/app.slint#L320), [`ui/dr-ui/ui/app.slint:575`](../../ui/dr-ui/ui/app.slint#L575), [`ui/dr-ui/ui/app.slint:896`](../../ui/dr-ui/ui/app.slint#L896), [`ui/dr-ui/ui/library.slint:111`](../../ui/dr-ui/ui/library.slint#L111), [`ui/dr-ui/ui/library.slint:1552`](../../ui/dr-ui/ui/library.slint#L1552), [`ui/dr-ui/ui/library.slint:4246`](../../ui/dr-ui/ui/library.slint#L4246), [`ui/dr-ui/ui/settings.slint:191`](../../ui/dr-ui/ui/settings.slint#L191) | +| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1117`](../../ui/dr-ui/src/collections_ui.rs#L1117), [`ui/dr-ui/src/collections_ui.rs:117`](../../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:1225`](../../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1704`](../../ui/dr-ui/src/collections_ui.rs#L1704), [`ui/dr-ui/src/collections_ui.rs:1750`](../../ui/dr-ui/src/collections_ui.rs#L1750), [`ui/dr-ui/src/collections_ui.rs:1`](../../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2000`](../../ui/dr-ui/src/collections_ui.rs#L2000), [`ui/dr-ui/src/collections_ui.rs:2065`](../../ui/dr-ui/src/collections_ui.rs#L2065), [`ui/dr-ui/src/collections_ui.rs:2125`](../../ui/dr-ui/src/collections_ui.rs#L2125), [`ui/dr-ui/src/collections_ui.rs:2701`](../../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:2755`](../../ui/dr-ui/src/collections_ui.rs#L2755), [`ui/dr-ui/src/collections_ui.rs:2801`](../../ui/dr-ui/src/collections_ui.rs#L2801), [`ui/dr-ui/src/collections_ui.rs:2903`](../../ui/dr-ui/src/collections_ui.rs#L2903), [`ui/dr-ui/src/collections_ui.rs:2959`](../../ui/dr-ui/src/collections_ui.rs#L2959), [`ui/dr-ui/src/collections_ui.rs:3120`](../../ui/dr-ui/src/collections_ui.rs#L3120), [`ui/dr-ui/src/collections_ui.rs:3142`](../../ui/dr-ui/src/collections_ui.rs#L3142), [`ui/dr-ui/src/collections_ui.rs:3208`](../../ui/dr-ui/src/collections_ui.rs#L3208), [`ui/dr-ui/src/collections_ui.rs:329`](../../ui/dr-ui/src/collections_ui.rs#L329), [`ui/dr-ui/src/collections_ui.rs:3328`](../../ui/dr-ui/src/collections_ui.rs#L3328), [`ui/dr-ui/src/collections_ui.rs:339`](../../ui/dr-ui/src/collections_ui.rs#L339), [`ui/dr-ui/src/collections_ui.rs:362`](../../ui/dr-ui/src/collections_ui.rs#L362), [`ui/dr-ui/src/collections_ui.rs:3633`](../../ui/dr-ui/src/collections_ui.rs#L3633), [`ui/dr-ui/src/collections_ui.rs:383`](../../ui/dr-ui/src/collections_ui.rs#L383), [`ui/dr-ui/src/collections_ui.rs:514`](../../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:718`](../../ui/dr-ui/src/collections_ui.rs#L718), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library/spans.rs:139`](../../ui/dr-ui/src/library/spans.rs#L139), [`ui/dr-ui/src/library/spans.rs:58`](../../ui/dr-ui/src/library/spans.rs#L58), [`ui/dr-ui/src/library/spans.rs:84`](../../ui/dr-ui/src/library/spans.rs#L84), [`ui/dr-ui/src/library_ui.rs:2774`](../../ui/dr-ui/src/library_ui.rs#L2774), [`ui/dr-ui/src/library_ui.rs:4369`](../../ui/dr-ui/src/library_ui.rs#L4369), [`ui/dr-ui/src/library_ui.rs:5128`](../../ui/dr-ui/src/library_ui.rs#L5128), [`ui/dr-ui/ui/app.slint:311`](../../ui/dr-ui/ui/app.slint#L311), [`ui/dr-ui/ui/app.slint:3213`](../../ui/dr-ui/ui/app.slint#L3213), [`ui/dr-ui/ui/app.slint:570`](../../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/app.slint:599`](../../ui/dr-ui/ui/app.slint#L599), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:587`](../../ui/dr-ui/ui/collections.slint#L587), [`ui/dr-ui/ui/library.slint:1327`](../../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:2942`](../../ui/dr-ui/ui/library.slint#L2942), [`ui/dr-ui/ui/library.slint:4224`](../../ui/dr-ui/ui/library.slint#L4224), [`ui/dr-ui/ui/library.slint:4311`](../../ui/dr-ui/ui/library.slint#L4311), [`ui/dr-ui/ui/library.slint:998`](../../ui/dr-ui/ui/library.slint#L998) | +| FR-CAT-8 | [`core/dr-catalog/src/schema.rs:740`](../../core/dr-catalog/src/schema.rs#L740), [`core/dr-pipeline/src/coverage.rs:1`](../../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/graph.rs:587`](../../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/graph.rs:640`](../../core/dr-pipeline/src/graph.rs#L640), [`core/dr-pipeline/src/ops/curve.rs:137`](../../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1400`](../../core/dr-pipeline/src/sidecar.rs#L1400), [`core/dr-pipeline/src/sidecar.rs:2232`](../../core/dr-pipeline/src/sidecar.rs#L2232), [`core/dr-pipeline/src/sidecar.rs:96`](../../core/dr-pipeline/src/sidecar.rs#L96), [`core/dr-pipeline/src/state.rs:1`](../../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../../core/dr-pipeline/tests/tone_curve.rs#L34), [`core/dr-sync/src/scan.rs:31`](../../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../../core/dr-sync/src/scan.rs#L926), [`ui/dr-ui/src/develop/mask_ops.rs:1076`](../../ui/dr-ui/src/develop/mask_ops.rs#L1076), [`ui/dr-ui/src/develop/mask_ops.rs:1130`](../../ui/dr-ui/src/develop/mask_ops.rs#L1130), [`ui/dr-ui/src/develop/mask_ops.rs:178`](../../ui/dr-ui/src/develop/mask_ops.rs#L178), [`ui/dr-ui/src/develop/masks.rs:740`](../../ui/dr-ui/src/develop/masks.rs#L740), [`ui/dr-ui/src/develop/session.rs:561`](../../ui/dr-ui/src/develop/session.rs#L561), [`ui/dr-ui/src/develop/session.rs:590`](../../ui/dr-ui/src/develop/session.rs#L590), [`ui/dr-ui/src/export.rs:812`](../../ui/dr-ui/src/export.rs#L812), [`ui/dr-ui/src/lib.rs:1330`](../../ui/dr-ui/src/lib.rs#L1330), [`ui/dr-ui/src/lib.rs:1888`](../../ui/dr-ui/src/lib.rs#L1888), [`ui/dr-ui/src/lib.rs:2457`](../../ui/dr-ui/src/lib.rs#L2457), [`ui/dr-ui/src/lib.rs:2604`](../../ui/dr-ui/src/lib.rs#L2604), [`ui/dr-ui/src/lib.rs:757`](../../ui/dr-ui/src/lib.rs#L757), [`ui/dr-ui/src/library/scan.rs:226`](../../ui/dr-ui/src/library/scan.rs#L226), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:34`](../../ui/dr-ui/src/library/scan.rs#L34), [`ui/dr-ui/src/library/sidecar.rs:100`](../../ui/dr-ui/src/library/sidecar.rs#L100), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11), [`ui/dr-ui/src/library/sidecar.rs:373`](../../ui/dr-ui/src/library/sidecar.rs#L373), [`ui/dr-ui/src/library/sidecar.rs:63`](../../ui/dr-ui/src/library/sidecar.rs#L63), [`ui/dr-ui/src/library/thumbnails_fetch.rs:413`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L413), [`ui/dr-ui/src/library_ui.rs:1339`](../../ui/dr-ui/src/library_ui.rs#L1339), [`ui/dr-ui/src/library_ui.rs:6350`](../../ui/dr-ui/src/library_ui.rs#L6350), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:1182`](../../core/dr-catalog/src/schema.rs#L1182), [`core/dr-catalog/src/walk.rs:1022`](../../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:162`](../../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:728`](../../core/dr-catalog/src/walk.rs#L728), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/error.rs:113`](../../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/reachability.rs:1`](../../core/dr-sync/src/reachability.rs#L1), [`core/dr-sync/src/scan.rs:211`](../../core/dr-sync/src/scan.rs#L211), [`core/dr-sync/src/scan.rs:519`](../../core/dr-sync/src/scan.rs#L519), [`core/dr-types/src/lib.rs:122`](../../core/dr-types/src/lib.rs#L122), [`ui/dr-ui/src/develop/render.rs:584`](../../ui/dr-ui/src/develop/render.rs#L584), [`ui/dr-ui/src/develop/render.rs:627`](../../ui/dr-ui/src/develop/render.rs#L627), [`ui/dr-ui/src/library/filters.rs:84`](../../ui/dr-ui/src/library/filters.rs#L84), [`ui/dr-ui/src/library/scan.rs:126`](../../ui/dr-ui/src/library/scan.rs#L126), [`ui/dr-ui/src/library/scan.rs:179`](../../ui/dr-ui/src/library/scan.rs#L179), [`ui/dr-ui/src/library/scan.rs:210`](../../ui/dr-ui/src/library/scan.rs#L210), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:605`](../../ui/dr-ui/src/library/scan.rs#L605), [`ui/dr-ui/src/library/sidecar.rs:100`](../../ui/dr-ui/src/library/sidecar.rs#L100), [`ui/dr-ui/src/library/sidecar.rs:357`](../../ui/dr-ui/src/library/sidecar.rs#L357), [`ui/dr-ui/src/library/sidecar.rs:373`](../../ui/dr-ui/src/library/sidecar.rs#L373), [`ui/dr-ui/src/library/sidecar.rs:427`](../../ui/dr-ui/src/library/sidecar.rs#L427), [`ui/dr-ui/src/library/spans.rs:361`](../../ui/dr-ui/src/library/spans.rs#L361), [`ui/dr-ui/src/library/thumbnails_fetch.rs:393`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L393), [`ui/dr-ui/src/library/thumbnails_fetch.rs:449`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L449), [`ui/dr-ui/src/library/thumbnails_gen.rs:74`](../../ui/dr-ui/src/library/thumbnails_gen.rs#L74), [`ui/dr-ui/src/library_ui.rs:1406`](../../ui/dr-ui/src/library_ui.rs#L1406), [`ui/dr-ui/src/library_ui.rs:1997`](../../ui/dr-ui/src/library_ui.rs#L1997), [`ui/dr-ui/src/library_ui.rs:2041`](../../ui/dr-ui/src/library_ui.rs#L2041), [`ui/dr-ui/src/library_ui.rs:2057`](../../ui/dr-ui/src/library_ui.rs#L2057), [`ui/dr-ui/src/library_ui.rs:2151`](../../ui/dr-ui/src/library_ui.rs#L2151), [`ui/dr-ui/src/library_ui.rs:284`](../../ui/dr-ui/src/library_ui.rs#L284), [`ui/dr-ui/src/library_ui.rs:2993`](../../ui/dr-ui/src/library_ui.rs#L2993), [`ui/dr-ui/src/library_ui.rs:320`](../../ui/dr-ui/src/library_ui.rs#L320), [`ui/dr-ui/src/library_ui.rs:328`](../../ui/dr-ui/src/library_ui.rs#L328), [`ui/dr-ui/src/library_ui.rs:3616`](../../ui/dr-ui/src/library_ui.rs#L3616), [`ui/dr-ui/src/library_ui.rs:3838`](../../ui/dr-ui/src/library_ui.rs#L3838), [`ui/dr-ui/src/library_ui.rs:4116`](../../ui/dr-ui/src/library_ui.rs#L4116), [`ui/dr-ui/src/library_ui.rs:4232`](../../ui/dr-ui/src/library_ui.rs#L4232), [`ui/dr-ui/src/library_ui.rs:4420`](../../ui/dr-ui/src/library_ui.rs#L4420), [`ui/dr-ui/src/library_ui.rs:4542`](../../ui/dr-ui/src/library_ui.rs#L4542), [`ui/dr-ui/src/library_ui.rs:573`](../../ui/dr-ui/src/library_ui.rs#L573), [`ui/dr-ui/src/library_ui.rs:662`](../../ui/dr-ui/src/library_ui.rs#L662), [`ui/dr-ui/src/library_ui.rs:6864`](../../ui/dr-ui/src/library_ui.rs#L6864), [`ui/dr-ui/src/library_ui.rs:6946`](../../ui/dr-ui/src/library_ui.rs#L6946), [`ui/dr-ui/src/presets.rs:486`](../../ui/dr-ui/src/presets.rs#L486), [`ui/dr-ui/src/presets.rs:498`](../../ui/dr-ui/src/presets.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../../core/dr-decode/src/preview.rs#L121) | -| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`core/dr-catalog/src/schema.rs:603`](../../core/dr-catalog/src/schema.rs#L603), [`core/dr-catalog/src/schema.rs:781`](../../core/dr-catalog/src/schema.rs#L781), [`core/dr-face/src/assign.rs:1`](../../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../../core/dr-face/src/neighbours.rs#L1), [`core/dr-face/src/references.rs:1`](../../core/dr-face/src/references.rs#L1), [`core/dr-types/src/settings.rs:129`](../../core/dr-types/src/settings.rs#L129), [`ui/dr-ui/src/develop.rs:121`](../../ui/dr-ui/src/develop.rs#L121), [`ui/dr-ui/src/develop.rs:130`](../../ui/dr-ui/src/develop.rs#L130), [`ui/dr-ui/src/develop.rs:196`](../../ui/dr-ui/src/develop.rs#L196), [`ui/dr-ui/src/develop.rs:2416`](../../ui/dr-ui/src/develop.rs#L2416), [`ui/dr-ui/src/develop.rs:715`](../../ui/dr-ui/src/develop.rs#L715), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1325`](../../ui/dr-ui/src/identity_ui.rs#L1325), [`ui/dr-ui/src/identity_ui.rs:1`](../../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2576`](../../ui/dr-ui/src/lib.rs#L2576), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1), [`ui/dr-ui/ui/settings.slint:529`](../../ui/dr-ui/ui/settings.slint#L529), [`ui/dr-ui/ui/settings.slint:94`](../../ui/dr-ui/ui/settings.slint#L94) | -| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:295`](../../ui/dr-ui/src/library.rs#L295), [`ui/dr-ui/src/library.rs:345`](../../ui/dr-ui/src/library.rs#L345), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1) | +| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`core/dr-catalog/src/schema.rs:603`](../../core/dr-catalog/src/schema.rs#L603), [`core/dr-catalog/src/schema.rs:781`](../../core/dr-catalog/src/schema.rs#L781), [`core/dr-face/src/assign.rs:1`](../../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../../core/dr-face/src/neighbours.rs#L1), [`core/dr-face/src/references.rs:1`](../../core/dr-face/src/references.rs#L1), [`core/dr-types/src/settings.rs:129`](../../core/dr-types/src/settings.rs#L129), [`ui/dr-ui/src/develop/segmentation.rs:104`](../../ui/dr-ui/src/develop/segmentation.rs#L104), [`ui/dr-ui/src/develop/segmentation.rs:170`](../../ui/dr-ui/src/develop/segmentation.rs#L170), [`ui/dr-ui/src/develop/segmentation.rs:590`](../../ui/dr-ui/src/develop/segmentation.rs#L590), [`ui/dr-ui/src/develop/segmentation.rs:95`](../../ui/dr-ui/src/develop/segmentation.rs#L95), [`ui/dr-ui/src/develop/session.rs:23`](../../ui/dr-ui/src/develop/session.rs#L23), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1325`](../../ui/dr-ui/src/identity_ui.rs#L1325), [`ui/dr-ui/src/identity_ui.rs:1`](../../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2576`](../../ui/dr-ui/src/lib.rs#L2576), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1), [`ui/dr-ui/ui/settings.slint:529`](../../ui/dr-ui/ui/settings.slint#L529), [`ui/dr-ui/ui/settings.slint:94`](../../ui/dr-ui/ui/settings.slint#L94) | +| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library/filters.rs:105`](../../ui/dr-ui/src/library/filters.rs#L105), [`ui/dr-ui/src/library/filters.rs:155`](../../ui/dr-ui/src/library/filters.rs#L155), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`core/dr-catalog/src/schema.rs:603`](../../core/dr-catalog/src/schema.rs#L603), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1) | -| FR-CULL-13 | [`core/dr-catalog/src/schema.rs:917`](../../core/dr-catalog/src/schema.rs#L917), [`core/dr-types/src/place.rs:129`](../../core/dr-types/src/place.rs#L129), [`ui/dr-ui/src/identity.rs:104`](../../ui/dr-ui/src/identity.rs#L104), [`ui/dr-ui/src/library.rs:321`](../../ui/dr-ui/src/library.rs#L321), [`ui/dr-ui/src/library.rs:7219`](../../ui/dr-ui/src/library.rs#L7219), [`ui/dr-ui/src/library.rs:7262`](../../ui/dr-ui/src/library.rs#L7262), [`ui/dr-ui/src/library.rs:7300`](../../ui/dr-ui/src/library.rs#L7300), [`ui/dr-ui/ui/app.slint:707`](../../ui/dr-ui/ui/app.slint#L707), [`ui/dr-ui/ui/identity.slint:63`](../../ui/dr-ui/ui/identity.slint#L63), [`ui/dr-ui/ui/library.slint:1512`](../../ui/dr-ui/ui/library.slint#L1512) | +| FR-CULL-13 | [`core/dr-catalog/src/schema.rs:917`](../../core/dr-catalog/src/schema.rs#L917), [`core/dr-types/src/place.rs:129`](../../core/dr-types/src/place.rs#L129), [`ui/dr-ui/src/identity.rs:104`](../../ui/dr-ui/src/identity.rs#L104), [`ui/dr-ui/src/library/filters.rs:131`](../../ui/dr-ui/src/library/filters.rs#L131), [`ui/dr-ui/src/library/filters.rs:683`](../../ui/dr-ui/src/library/filters.rs#L683), [`ui/dr-ui/src/library/filters.rs:726`](../../ui/dr-ui/src/library/filters.rs#L726), [`ui/dr-ui/src/library/filters.rs:764`](../../ui/dr-ui/src/library/filters.rs#L764), [`ui/dr-ui/ui/app.slint:707`](../../ui/dr-ui/ui/app.slint#L707), [`ui/dr-ui/ui/identity.slint:63`](../../ui/dr-ui/ui/identity.slint#L63), [`ui/dr-ui/ui/library.slint:1512`](../../ui/dr-ui/ui/library.slint#L1512) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:463`](../../ui/dr-ui/src/import.rs#L463) | -| FR-CULL-3 | [`core/dr-gpu/src/focus.rs:154`](../../core/dr-gpu/src/focus.rs#L154), [`core/dr-gpu/src/focus.rs:186`](../../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/raw_histogram.rs:129`](../../core/dr-gpu/src/raw_histogram.rs#L129), [`core/dr-gpu/src/raw_histogram.rs:1`](../../core/dr-gpu/src/raw_histogram.rs#L1), [`core/dr-gpu/src/raw_histogram.rs:272`](../../core/dr-gpu/src/raw_histogram.rs#L272), [`core/dr-gpu/src/raw_histogram.rs:407`](../../core/dr-gpu/src/raw_histogram.rs#L407), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`core/dr-gpu/src/shaders/raw_histogram.wgsl:1`](../../core/dr-gpu/src/shaders/raw_histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:4146`](../../ui/dr-ui/src/develop.rs#L4146), [`ui/dr-ui/src/develop.rs:4159`](../../ui/dr-ui/src/develop.rs#L4159), [`ui/dr-ui/src/develop.rs:4204`](../../ui/dr-ui/src/develop.rs#L4204), [`ui/dr-ui/src/develop.rs:4215`](../../ui/dr-ui/src/develop.rs#L4215), [`ui/dr-ui/src/develop.rs:4221`](../../ui/dr-ui/src/develop.rs#L4221), [`ui/dr-ui/src/develop.rs:4238`](../../ui/dr-ui/src/develop.rs#L4238), [`ui/dr-ui/src/develop.rs:810`](../../ui/dr-ui/src/develop.rs#L810), [`ui/dr-ui/src/develop.rs:830`](../../ui/dr-ui/src/develop.rs#L830), [`ui/dr-ui/src/develop.rs:836`](../../ui/dr-ui/src/develop.rs#L836), [`ui/dr-ui/src/develop.rs:8992`](../../ui/dr-ui/src/develop.rs#L8992), [`ui/dr-ui/src/develop.rs:9056`](../../ui/dr-ui/src/develop.rs#L9056), [`ui/dr-ui/src/develop.rs:9079`](../../ui/dr-ui/src/develop.rs#L9079), [`ui/dr-ui/src/histogram.rs:1`](../../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/histogram.rs:208`](../../ui/dr-ui/src/histogram.rs#L208), [`ui/dr-ui/src/histogram.rs:228`](../../ui/dr-ui/src/histogram.rs#L228), [`ui/dr-ui/src/histogram.rs:272`](../../ui/dr-ui/src/histogram.rs#L272), [`ui/dr-ui/src/histogram.rs:544`](../../ui/dr-ui/src/histogram.rs#L544), [`ui/dr-ui/src/histogram.rs:565`](../../ui/dr-ui/src/histogram.rs#L565), [`ui/dr-ui/src/histogram.rs:593`](../../ui/dr-ui/src/histogram.rs#L593), [`ui/dr-ui/src/histogram.rs:621`](../../ui/dr-ui/src/histogram.rs#L621), [`ui/dr-ui/src/histogram.rs:658`](../../ui/dr-ui/src/histogram.rs#L658), [`ui/dr-ui/src/lib.rs:1932`](../../ui/dr-ui/src/lib.rs#L1932), [`ui/dr-ui/src/lib.rs:2040`](../../ui/dr-ui/src/lib.rs#L2040), [`ui/dr-ui/src/lib.rs:2153`](../../ui/dr-ui/src/lib.rs#L2153), [`ui/dr-ui/src/lib.rs:2194`](../../ui/dr-ui/src/lib.rs#L2194), [`ui/dr-ui/src/lib.rs:3685`](../../ui/dr-ui/src/lib.rs#L3685), [`ui/dr-ui/src/lib.rs:442`](../../ui/dr-ui/src/lib.rs#L442), [`ui/dr-ui/src/peaking.rs:1`](../../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:2028`](../../ui/dr-ui/ui/app.slint#L2028), [`ui/dr-ui/ui/app.slint:3035`](../../ui/dr-ui/ui/app.slint#L3035), [`ui/dr-ui/ui/app.slint:90`](../../ui/dr-ui/ui/app.slint#L90), [`ui/dr-ui/ui/peaking.slint:1`](../../ui/dr-ui/ui/peaking.slint#L1), [`ui/dr-ui/ui/peaking.slint:25`](../../ui/dr-ui/ui/peaking.slint#L25), [`ui/dr-ui/ui/peaking.slint:56`](../../ui/dr-ui/ui/peaking.slint#L56), [`ui/dr-ui/ui/peaking.slint:91`](../../ui/dr-ui/ui/peaking.slint#L91) | -| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:171`](../../core/dr-pipeline/src/sidecar.rs#L171), [`core/dr-xmp/src/lib.rs:442`](../../core/dr-xmp/src/lib.rs#L442), [`ui/dr-ui/src/library.rs:254`](../../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:564`](../../ui/dr-ui/src/library.rs#L564) | -| FR-CULL-5 | [`core/dr-catalog/src/bursts.rs:1`](../../core/dr-catalog/src/bursts.rs#L1), [`core/dr-catalog/src/schema.rs:646`](../../core/dr-catalog/src/schema.rs#L646), [`ui/dr-ui/src/bursts.rs:1`](../../ui/dr-ui/src/bursts.rs#L1), [`ui/dr-ui/src/library.rs:210`](../../ui/dr-ui/src/library.rs#L210), [`ui/dr-ui/src/library.rs:6039`](../../ui/dr-ui/src/library.rs#L6039) | -| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`core/dr-catalog/src/schema.rs:714`](../../core/dr-catalog/src/schema.rs#L714), [`core/dr-catalog/src/schema.rs:970`](../../core/dr-catalog/src/schema.rs#L970), [`core/dr-face/src/align.rs:296`](../../core/dr-face/src/align.rs#L296), [`core/dr-types/src/settings.rs:176`](../../core/dr-types/src/settings.rs#L176), [`ui/dr-ui/examples/face_detectors.rs:1`](../../ui/dr-ui/examples/face_detectors.rs#L1), [`ui/dr-ui/examples/face_native.rs:1`](../../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/derived_sync.rs:124`](../../ui/dr-ui/src/derived_sync.rs#L124), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:446`](../../ui/dr-ui/src/faces.rs#L446), [`ui/dr-ui/src/faces.rs:462`](../../ui/dr-ui/src/faces.rs#L462), [`ui/dr-ui/src/faces.rs:584`](../../ui/dr-ui/src/faces.rs#L584), [`ui/dr-ui/src/identity_ui.rs:1325`](../../ui/dr-ui/src/identity_ui.rs#L1325), [`ui/dr-ui/src/identity_ui.rs:603`](../../ui/dr-ui/src/identity_ui.rs#L603), [`ui/dr-ui/src/lib.rs:1511`](../../ui/dr-ui/src/lib.rs#L1511), [`ui/dr-ui/src/lib.rs:3940`](../../ui/dr-ui/src/lib.rs#L3940), [`ui/dr-ui/src/library.rs:3624`](../../ui/dr-ui/src/library.rs#L3624), [`ui/dr-ui/src/library.rs:3889`](../../ui/dr-ui/src/library.rs#L3889), [`ui/dr-ui/src/library_ui.rs:406`](../../ui/dr-ui/src/library_ui.rs#L406), [`ui/dr-ui/src/library_ui.rs:493`](../../ui/dr-ui/src/library_ui.rs#L493), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/src/settings_ui.rs:210`](../../ui/dr-ui/src/settings_ui.rs#L210), [`ui/dr-ui/src/settings_ui.rs:552`](../../ui/dr-ui/src/settings_ui.rs#L552), [`ui/dr-ui/ui/app.slint:946`](../../ui/dr-ui/ui/app.slint#L946), [`ui/dr-ui/ui/settings.slint:100`](../../ui/dr-ui/ui/settings.slint#L100), [`ui/dr-ui/ui/settings.slint:467`](../../ui/dr-ui/ui/settings.slint#L467), [`ui/dr-ui/ui/settings.slint:529`](../../ui/dr-ui/ui/settings.slint#L529), [`ui/dr-ui/ui/settings.slint:82`](../../ui/dr-ui/ui/settings.slint#L82), [`ui/dr-ui/ui/settings.slint:94`](../../ui/dr-ui/ui/settings.slint#L94) | -| FR-CULL-8a | [`core/dr-catalog/src/faces.rs:124`](../../core/dr-catalog/src/faces.rs#L124), [`core/dr-catalog/src/schema.rs:917`](../../core/dr-catalog/src/schema.rs#L917), [`core/dr-face/src/classify.rs:1`](../../core/dr-face/src/classify.rs#L1), [`core/dr-face/src/eyes.rs:1`](../../core/dr-face/src/eyes.rs#L1), [`core/dr-face/src/landmarks.rs:1`](../../core/dr-face/src/landmarks.rs#L1), [`core/dr-types/src/place.rs:129`](../../core/dr-types/src/place.rs#L129), [`ui/dr-ui/src/faces.rs:166`](../../ui/dr-ui/src/faces.rs#L166), [`ui/dr-ui/src/faces.rs:1821`](../../ui/dr-ui/src/faces.rs#L1821), [`ui/dr-ui/src/faces.rs:308`](../../ui/dr-ui/src/faces.rs#L308), [`ui/dr-ui/src/identity.rs:104`](../../ui/dr-ui/src/identity.rs#L104), [`ui/dr-ui/src/library.rs:321`](../../ui/dr-ui/src/library.rs#L321), [`ui/dr-ui/src/library.rs:7219`](../../ui/dr-ui/src/library.rs#L7219), [`ui/dr-ui/src/library.rs:7262`](../../ui/dr-ui/src/library.rs#L7262), [`ui/dr-ui/src/library.rs:7300`](../../ui/dr-ui/src/library.rs#L7300), [`ui/dr-ui/src/repairs.rs:571`](../../ui/dr-ui/src/repairs.rs#L571), [`ui/dr-ui/ui/app.slint:707`](../../ui/dr-ui/ui/app.slint#L707), [`ui/dr-ui/ui/identity.slint:273`](../../ui/dr-ui/ui/identity.slint#L273), [`ui/dr-ui/ui/identity.slint:63`](../../ui/dr-ui/ui/identity.slint#L63), [`ui/dr-ui/ui/library.slint:1512`](../../ui/dr-ui/ui/library.slint#L1512) | +| FR-CULL-3 | [`core/dr-gpu/src/focus.rs:154`](../../core/dr-gpu/src/focus.rs#L154), [`core/dr-gpu/src/focus.rs:186`](../../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/raw_histogram.rs:129`](../../core/dr-gpu/src/raw_histogram.rs#L129), [`core/dr-gpu/src/raw_histogram.rs:1`](../../core/dr-gpu/src/raw_histogram.rs#L1), [`core/dr-gpu/src/raw_histogram.rs:272`](../../core/dr-gpu/src/raw_histogram.rs#L272), [`core/dr-gpu/src/raw_histogram.rs:407`](../../core/dr-gpu/src/raw_histogram.rs#L407), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`core/dr-gpu/src/shaders/raw_histogram.wgsl:1`](../../core/dr-gpu/src/shaders/raw_histogram.wgsl#L1), [`ui/dr-ui/src/develop/render.rs:1203`](../../ui/dr-ui/src/develop/render.rs#L1203), [`ui/dr-ui/src/develop/render.rs:1267`](../../ui/dr-ui/src/develop/render.rs#L1267), [`ui/dr-ui/src/develop/render.rs:1290`](../../ui/dr-ui/src/develop/render.rs#L1290), [`ui/dr-ui/src/develop/render.rs:256`](../../ui/dr-ui/src/develop/render.rs#L256), [`ui/dr-ui/src/develop/render.rs:269`](../../ui/dr-ui/src/develop/render.rs#L269), [`ui/dr-ui/src/develop/render.rs:314`](../../ui/dr-ui/src/develop/render.rs#L314), [`ui/dr-ui/src/develop/render.rs:325`](../../ui/dr-ui/src/develop/render.rs#L325), [`ui/dr-ui/src/develop/render.rs:331`](../../ui/dr-ui/src/develop/render.rs#L331), [`ui/dr-ui/src/develop/render.rs:348`](../../ui/dr-ui/src/develop/render.rs#L348), [`ui/dr-ui/src/develop/session.rs:118`](../../ui/dr-ui/src/develop/session.rs#L118), [`ui/dr-ui/src/develop/session.rs:138`](../../ui/dr-ui/src/develop/session.rs#L138), [`ui/dr-ui/src/develop/session.rs:144`](../../ui/dr-ui/src/develop/session.rs#L144), [`ui/dr-ui/src/histogram.rs:1`](../../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/histogram.rs:208`](../../ui/dr-ui/src/histogram.rs#L208), [`ui/dr-ui/src/histogram.rs:228`](../../ui/dr-ui/src/histogram.rs#L228), [`ui/dr-ui/src/histogram.rs:272`](../../ui/dr-ui/src/histogram.rs#L272), [`ui/dr-ui/src/histogram.rs:544`](../../ui/dr-ui/src/histogram.rs#L544), [`ui/dr-ui/src/histogram.rs:565`](../../ui/dr-ui/src/histogram.rs#L565), [`ui/dr-ui/src/histogram.rs:593`](../../ui/dr-ui/src/histogram.rs#L593), [`ui/dr-ui/src/histogram.rs:621`](../../ui/dr-ui/src/histogram.rs#L621), [`ui/dr-ui/src/histogram.rs:658`](../../ui/dr-ui/src/histogram.rs#L658), [`ui/dr-ui/src/lib.rs:1932`](../../ui/dr-ui/src/lib.rs#L1932), [`ui/dr-ui/src/lib.rs:2040`](../../ui/dr-ui/src/lib.rs#L2040), [`ui/dr-ui/src/lib.rs:2153`](../../ui/dr-ui/src/lib.rs#L2153), [`ui/dr-ui/src/lib.rs:2194`](../../ui/dr-ui/src/lib.rs#L2194), [`ui/dr-ui/src/lib.rs:3685`](../../ui/dr-ui/src/lib.rs#L3685), [`ui/dr-ui/src/lib.rs:442`](../../ui/dr-ui/src/lib.rs#L442), [`ui/dr-ui/src/peaking.rs:1`](../../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:2028`](../../ui/dr-ui/ui/app.slint#L2028), [`ui/dr-ui/ui/app.slint:3035`](../../ui/dr-ui/ui/app.slint#L3035), [`ui/dr-ui/ui/app.slint:90`](../../ui/dr-ui/ui/app.slint#L90), [`ui/dr-ui/ui/peaking.slint:1`](../../ui/dr-ui/ui/peaking.slint#L1), [`ui/dr-ui/ui/peaking.slint:25`](../../ui/dr-ui/ui/peaking.slint#L25), [`ui/dr-ui/ui/peaking.slint:56`](../../ui/dr-ui/ui/peaking.slint#L56), [`ui/dr-ui/ui/peaking.slint:91`](../../ui/dr-ui/ui/peaking.slint#L91) | +| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:171`](../../core/dr-pipeline/src/sidecar.rs#L171), [`core/dr-xmp/src/lib.rs:442`](../../core/dr-xmp/src/lib.rs#L442), [`ui/dr-ui/src/library/filters.rs:64`](../../ui/dr-ui/src/library/filters.rs#L64), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11) | +| FR-CULL-5 | [`core/dr-catalog/src/bursts.rs:1`](../../core/dr-catalog/src/bursts.rs#L1), [`core/dr-catalog/src/schema.rs:646`](../../core/dr-catalog/src/schema.rs#L646), [`ui/dr-ui/src/bursts.rs:1`](../../ui/dr-ui/src/bursts.rs#L1), [`ui/dr-ui/src/library/cells.rs:19`](../../ui/dr-ui/src/library/cells.rs#L19), [`ui/dr-ui/src/library/cells.rs:769`](../../ui/dr-ui/src/library/cells.rs#L769) | +| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`core/dr-catalog/src/schema.rs:714`](../../core/dr-catalog/src/schema.rs#L714), [`core/dr-catalog/src/schema.rs:970`](../../core/dr-catalog/src/schema.rs#L970), [`core/dr-face/src/align.rs:296`](../../core/dr-face/src/align.rs#L296), [`core/dr-types/src/settings.rs:176`](../../core/dr-types/src/settings.rs#L176), [`ui/dr-ui/examples/face_detectors.rs:1`](../../ui/dr-ui/examples/face_detectors.rs#L1), [`ui/dr-ui/examples/face_native.rs:1`](../../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/derived_sync.rs:124`](../../ui/dr-ui/src/derived_sync.rs#L124), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:446`](../../ui/dr-ui/src/faces.rs#L446), [`ui/dr-ui/src/faces.rs:462`](../../ui/dr-ui/src/faces.rs#L462), [`ui/dr-ui/src/faces.rs:584`](../../ui/dr-ui/src/faces.rs#L584), [`ui/dr-ui/src/identity_ui.rs:1325`](../../ui/dr-ui/src/identity_ui.rs#L1325), [`ui/dr-ui/src/identity_ui.rs:603`](../../ui/dr-ui/src/identity_ui.rs#L603), [`ui/dr-ui/src/lib.rs:1511`](../../ui/dr-ui/src/lib.rs#L1511), [`ui/dr-ui/src/lib.rs:3940`](../../ui/dr-ui/src/lib.rs#L3940), [`ui/dr-ui/src/library/sweep.rs:241`](../../ui/dr-ui/src/library/sweep.rs#L241), [`ui/dr-ui/src/library/sweep.rs:506`](../../ui/dr-ui/src/library/sweep.rs#L506), [`ui/dr-ui/src/library_ui.rs:406`](../../ui/dr-ui/src/library_ui.rs#L406), [`ui/dr-ui/src/library_ui.rs:493`](../../ui/dr-ui/src/library_ui.rs#L493), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/src/settings_ui.rs:210`](../../ui/dr-ui/src/settings_ui.rs#L210), [`ui/dr-ui/src/settings_ui.rs:552`](../../ui/dr-ui/src/settings_ui.rs#L552), [`ui/dr-ui/ui/app.slint:946`](../../ui/dr-ui/ui/app.slint#L946), [`ui/dr-ui/ui/settings.slint:100`](../../ui/dr-ui/ui/settings.slint#L100), [`ui/dr-ui/ui/settings.slint:467`](../../ui/dr-ui/ui/settings.slint#L467), [`ui/dr-ui/ui/settings.slint:529`](../../ui/dr-ui/ui/settings.slint#L529), [`ui/dr-ui/ui/settings.slint:82`](../../ui/dr-ui/ui/settings.slint#L82), [`ui/dr-ui/ui/settings.slint:94`](../../ui/dr-ui/ui/settings.slint#L94) | +| FR-CULL-8a | [`core/dr-catalog/src/faces.rs:124`](../../core/dr-catalog/src/faces.rs#L124), [`core/dr-catalog/src/schema.rs:917`](../../core/dr-catalog/src/schema.rs#L917), [`core/dr-face/src/classify.rs:1`](../../core/dr-face/src/classify.rs#L1), [`core/dr-face/src/eyes.rs:1`](../../core/dr-face/src/eyes.rs#L1), [`core/dr-face/src/landmarks.rs:1`](../../core/dr-face/src/landmarks.rs#L1), [`core/dr-types/src/place.rs:129`](../../core/dr-types/src/place.rs#L129), [`ui/dr-ui/src/faces.rs:166`](../../ui/dr-ui/src/faces.rs#L166), [`ui/dr-ui/src/faces.rs:1821`](../../ui/dr-ui/src/faces.rs#L1821), [`ui/dr-ui/src/faces.rs:308`](../../ui/dr-ui/src/faces.rs#L308), [`ui/dr-ui/src/identity.rs:104`](../../ui/dr-ui/src/identity.rs#L104), [`ui/dr-ui/src/library/filters.rs:131`](../../ui/dr-ui/src/library/filters.rs#L131), [`ui/dr-ui/src/library/filters.rs:683`](../../ui/dr-ui/src/library/filters.rs#L683), [`ui/dr-ui/src/library/filters.rs:726`](../../ui/dr-ui/src/library/filters.rs#L726), [`ui/dr-ui/src/library/filters.rs:764`](../../ui/dr-ui/src/library/filters.rs#L764), [`ui/dr-ui/src/repairs.rs:571`](../../ui/dr-ui/src/repairs.rs#L571), [`ui/dr-ui/ui/app.slint:707`](../../ui/dr-ui/ui/app.slint#L707), [`ui/dr-ui/ui/identity.slint:273`](../../ui/dr-ui/ui/identity.slint#L273), [`ui/dr-ui/ui/identity.slint:63`](../../ui/dr-ui/ui/identity.slint#L63), [`ui/dr-ui/ui/library.slint:1512`](../../ui/dr-ui/ui/library.slint#L1512) | | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`core/dr-catalog/src/schema.rs:781`](../../core/dr-catalog/src/schema.rs#L781), [`core/dr-face/src/assign.rs:1`](../../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:129`](../../core/dr-types/src/settings.rs#L129), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:584`](../../ui/dr-ui/src/faces.rs#L584), [`ui/dr-ui/src/identity_ui.rs:1`](../../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/repairs.rs:517`](../../ui/dr-ui/src/repairs.rs#L517), [`ui/dr-ui/ui/identity.slint:347`](../../ui/dr-ui/ui/identity.slint#L347) | | FR-DEV-1 | [`core/dr-pipeline/src/graph.rs:1`](../../core/dr-pipeline/src/graph.rs#L1), [`core/dr-pipeline/src/sidecar.rs:1`](../../core/dr-pipeline/src/sidecar.rs#L1) | -| FR-DEV-10 | [`core/dr-gpu/src/mask.rs:1027`](../../core/dr-gpu/src/mask.rs#L1027), [`core/dr-gpu/src/mask.rs:1251`](../../core/dr-gpu/src/mask.rs#L1251), [`core/dr-gpu/src/mask.rs:1509`](../../core/dr-gpu/src/mask.rs#L1509), [`core/dr-gpu/src/mask.rs:421`](../../core/dr-gpu/src/mask.rs#L421), [`core/dr-gpu/src/mask.rs:463`](../../core/dr-gpu/src/mask.rs#L463), [`core/dr-gpu/src/mask.rs:58`](../../core/dr-gpu/src/mask.rs#L58), [`core/dr-gpu/src/mask.rs:60`](../../core/dr-gpu/src/mask.rs#L60), [`core/dr-gpu/src/mask.rs:834`](../../core/dr-gpu/src/mask.rs#L834), [`core/dr-gpu/src/mask.rs:85`](../../core/dr-gpu/src/mask.rs#L85), [`core/dr-gpu/src/mask.rs:913`](../../core/dr-gpu/src/mask.rs#L913), [`core/dr-gpu/src/shaders/mask.wgsl:117`](../../core/dr-gpu/src/shaders/mask.wgsl#L117), [`core/dr-gpu/src/shaders/mask.wgsl:59`](../../core/dr-gpu/src/shaders/mask.wgsl#L59), [`core/dr-gpu/tests/range_masks.rs:119`](../../core/dr-gpu/tests/range_masks.rs#L119), [`core/dr-gpu/tests/range_masks.rs:147`](../../core/dr-gpu/tests/range_masks.rs#L147), [`core/dr-gpu/tests/range_masks.rs:175`](../../core/dr-gpu/tests/range_masks.rs#L175), [`core/dr-gpu/tests/range_masks.rs:1`](../../core/dr-gpu/tests/range_masks.rs#L1), [`core/dr-gpu/tests/range_masks.rs:94`](../../core/dr-gpu/tests/range_masks.rs#L94), [`core/dr-pipeline/src/mask.rs:125`](../../core/dr-pipeline/src/mask.rs#L125), [`core/dr-pipeline/src/mask.rs:2845`](../../core/dr-pipeline/src/mask.rs#L2845), [`core/dr-pipeline/src/mask.rs:2858`](../../core/dr-pipeline/src/mask.rs#L2858), [`core/dr-pipeline/src/mask.rs:2877`](../../core/dr-pipeline/src/mask.rs#L2877), [`core/dr-pipeline/src/mask.rs:2891`](../../core/dr-pipeline/src/mask.rs#L2891), [`core/dr-pipeline/src/mask.rs:2901`](../../core/dr-pipeline/src/mask.rs#L2901), [`core/dr-pipeline/src/mask.rs:710`](../../core/dr-pipeline/src/mask.rs#L710), [`core/dr-pipeline/src/mask.rs:749`](../../core/dr-pipeline/src/mask.rs#L749), [`core/dr-pipeline/src/mask.rs:809`](../../core/dr-pipeline/src/mask.rs#L809), [`core/dr-pipeline/src/mask.rs:828`](../../core/dr-pipeline/src/mask.rs#L828), [`core/dr-pipeline/src/mask.rs:852`](../../core/dr-pipeline/src/mask.rs#L852), [`core/dr-pipeline/src/mask.rs:864`](../../core/dr-pipeline/src/mask.rs#L864), [`core/dr-pipeline/src/mask.rs:876`](../../core/dr-pipeline/src/mask.rs#L876), [`core/dr-pipeline/src/sidecar.rs:1336`](../../core/dr-pipeline/src/sidecar.rs#L1336), [`core/dr-pipeline/src/sidecar.rs:1515`](../../core/dr-pipeline/src/sidecar.rs#L1515), [`core/dr-pipeline/src/sidecar.rs:1604`](../../core/dr-pipeline/src/sidecar.rs#L1604), [`core/dr-pipeline/src/sidecar.rs:1708`](../../core/dr-pipeline/src/sidecar.rs#L1708), [`core/dr-pipeline/src/sidecar.rs:1916`](../../core/dr-pipeline/src/sidecar.rs#L1916), [`core/dr-pipeline/tests/mask_sidecar.rs:1157`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1157), [`core/dr-pipeline/tests/mask_sidecar.rs:1186`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1186), [`core/dr-pipeline/tests/mask_sidecar.rs:1204`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1204), [`core/dr-pipeline/tests/mask_sidecar.rs:1234`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1234), [`ui/dr-ui/src/develop.rs:2318`](../../ui/dr-ui/src/develop.rs#L2318), [`ui/dr-ui/src/develop.rs:3666`](../../ui/dr-ui/src/develop.rs#L3666), [`ui/dr-ui/src/develop.rs:3693`](../../ui/dr-ui/src/develop.rs#L3693), [`ui/dr-ui/src/develop.rs:3725`](../../ui/dr-ui/src/develop.rs#L3725), [`ui/dr-ui/src/develop.rs:3749`](../../ui/dr-ui/src/develop.rs#L3749), [`ui/dr-ui/src/develop.rs:3755`](../../ui/dr-ui/src/develop.rs#L3755), [`ui/dr-ui/src/develop.rs:3762`](../../ui/dr-ui/src/develop.rs#L3762), [`ui/dr-ui/src/develop.rs:3782`](../../ui/dr-ui/src/develop.rs#L3782), [`ui/dr-ui/src/labels.rs:63`](../../ui/dr-ui/src/labels.rs#L63), [`ui/dr-ui/src/masks_ui.rs:1150`](../../ui/dr-ui/src/masks_ui.rs#L1150), [`ui/dr-ui/src/masks_ui.rs:748`](../../ui/dr-ui/src/masks_ui.rs#L748) | +| FR-DEV-10 | [`core/dr-gpu/src/mask.rs:1027`](../../core/dr-gpu/src/mask.rs#L1027), [`core/dr-gpu/src/mask.rs:1251`](../../core/dr-gpu/src/mask.rs#L1251), [`core/dr-gpu/src/mask.rs:1509`](../../core/dr-gpu/src/mask.rs#L1509), [`core/dr-gpu/src/mask.rs:421`](../../core/dr-gpu/src/mask.rs#L421), [`core/dr-gpu/src/mask.rs:463`](../../core/dr-gpu/src/mask.rs#L463), [`core/dr-gpu/src/mask.rs:58`](../../core/dr-gpu/src/mask.rs#L58), [`core/dr-gpu/src/mask.rs:60`](../../core/dr-gpu/src/mask.rs#L60), [`core/dr-gpu/src/mask.rs:834`](../../core/dr-gpu/src/mask.rs#L834), [`core/dr-gpu/src/mask.rs:85`](../../core/dr-gpu/src/mask.rs#L85), [`core/dr-gpu/src/mask.rs:913`](../../core/dr-gpu/src/mask.rs#L913), [`core/dr-gpu/src/shaders/mask.wgsl:117`](../../core/dr-gpu/src/shaders/mask.wgsl#L117), [`core/dr-gpu/src/shaders/mask.wgsl:59`](../../core/dr-gpu/src/shaders/mask.wgsl#L59), [`core/dr-gpu/tests/range_masks.rs:119`](../../core/dr-gpu/tests/range_masks.rs#L119), [`core/dr-gpu/tests/range_masks.rs:147`](../../core/dr-gpu/tests/range_masks.rs#L147), [`core/dr-gpu/tests/range_masks.rs:175`](../../core/dr-gpu/tests/range_masks.rs#L175), [`core/dr-gpu/tests/range_masks.rs:1`](../../core/dr-gpu/tests/range_masks.rs#L1), [`core/dr-gpu/tests/range_masks.rs:94`](../../core/dr-gpu/tests/range_masks.rs#L94), [`core/dr-pipeline/src/mask.rs:125`](../../core/dr-pipeline/src/mask.rs#L125), [`core/dr-pipeline/src/mask.rs:2845`](../../core/dr-pipeline/src/mask.rs#L2845), [`core/dr-pipeline/src/mask.rs:2858`](../../core/dr-pipeline/src/mask.rs#L2858), [`core/dr-pipeline/src/mask.rs:2877`](../../core/dr-pipeline/src/mask.rs#L2877), [`core/dr-pipeline/src/mask.rs:2891`](../../core/dr-pipeline/src/mask.rs#L2891), [`core/dr-pipeline/src/mask.rs:2901`](../../core/dr-pipeline/src/mask.rs#L2901), [`core/dr-pipeline/src/mask.rs:710`](../../core/dr-pipeline/src/mask.rs#L710), [`core/dr-pipeline/src/mask.rs:749`](../../core/dr-pipeline/src/mask.rs#L749), [`core/dr-pipeline/src/mask.rs:809`](../../core/dr-pipeline/src/mask.rs#L809), [`core/dr-pipeline/src/mask.rs:828`](../../core/dr-pipeline/src/mask.rs#L828), [`core/dr-pipeline/src/mask.rs:852`](../../core/dr-pipeline/src/mask.rs#L852), [`core/dr-pipeline/src/mask.rs:864`](../../core/dr-pipeline/src/mask.rs#L864), [`core/dr-pipeline/src/mask.rs:876`](../../core/dr-pipeline/src/mask.rs#L876), [`core/dr-pipeline/src/sidecar.rs:1336`](../../core/dr-pipeline/src/sidecar.rs#L1336), [`core/dr-pipeline/src/sidecar.rs:1515`](../../core/dr-pipeline/src/sidecar.rs#L1515), [`core/dr-pipeline/src/sidecar.rs:1604`](../../core/dr-pipeline/src/sidecar.rs#L1604), [`core/dr-pipeline/src/sidecar.rs:1708`](../../core/dr-pipeline/src/sidecar.rs#L1708), [`core/dr-pipeline/src/sidecar.rs:1916`](../../core/dr-pipeline/src/sidecar.rs#L1916), [`core/dr-pipeline/tests/mask_sidecar.rs:1157`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1157), [`core/dr-pipeline/tests/mask_sidecar.rs:1186`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1186), [`core/dr-pipeline/tests/mask_sidecar.rs:1204`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1204), [`core/dr-pipeline/tests/mask_sidecar.rs:1234`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1234), [`ui/dr-ui/src/develop/mask_ops.rs:341`](../../ui/dr-ui/src/develop/mask_ops.rs#L341), [`ui/dr-ui/src/develop/mask_ops.rs:638`](../../ui/dr-ui/src/develop/mask_ops.rs#L638), [`ui/dr-ui/src/develop/mask_ops.rs:665`](../../ui/dr-ui/src/develop/mask_ops.rs#L665), [`ui/dr-ui/src/develop/mask_ops.rs:697`](../../ui/dr-ui/src/develop/mask_ops.rs#L697), [`ui/dr-ui/src/develop/mask_ops.rs:721`](../../ui/dr-ui/src/develop/mask_ops.rs#L721), [`ui/dr-ui/src/develop/mask_ops.rs:727`](../../ui/dr-ui/src/develop/mask_ops.rs#L727), [`ui/dr-ui/src/develop/mask_ops.rs:734`](../../ui/dr-ui/src/develop/mask_ops.rs#L734), [`ui/dr-ui/src/develop/mask_ops.rs:754`](../../ui/dr-ui/src/develop/mask_ops.rs#L754), [`ui/dr-ui/src/labels.rs:63`](../../ui/dr-ui/src/labels.rs#L63), [`ui/dr-ui/src/masks_ui.rs:1150`](../../ui/dr-ui/src/masks_ui.rs#L1150), [`ui/dr-ui/src/masks_ui.rs:748`](../../ui/dr-ui/src/masks_ui.rs#L748) | | FR-DEV-12 | [`core/dr-pipeline/ops/colour_grading.yaml:1`](../../core/dr-pipeline/ops/colour_grading.yaml#L1) | | FR-DEV-16 | [`ui/dr-ui/src/lib.rs:424`](../../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/masks_ui.rs:670`](../../ui/dr-ui/src/masks_ui.rs#L670), [`ui/dr-ui/ui/adjust.slint:998`](../../ui/dr-ui/ui/adjust.slint#L998), [`ui/dr-ui/ui/app.slint:2620`](../../ui/dr-ui/ui/app.slint#L2620), [`ui/dr-ui/ui/app.slint:2633`](../../ui/dr-ui/ui/app.slint#L2633), [`ui/dr-ui/ui/masks.slint:832`](../../ui/dr-ui/ui/masks.slint#L832) | | FR-DEV-18 | [`core/dr-pipeline/src/ops/dehaze.rs:1`](../../core/dr-pipeline/src/ops/dehaze.rs#L1), [`core/dr-pipeline/src/ops/dehaze.rs:221`](../../core/dr-pipeline/src/ops/dehaze.rs#L221) | | FR-DEV-19 | [`core/dr-pipeline/src/mask.rs:1307`](../../core/dr-pipeline/src/mask.rs#L1307) | -| FR-DEV-19a | [`core/dr-gpu/src/mask.rs:761`](../../core/dr-gpu/src/mask.rs#L761), [`core/dr-pipeline/src/mask.rs:1588`](../../core/dr-pipeline/src/mask.rs#L1588), [`core/dr-pipeline/src/mask.rs:2919`](../../core/dr-pipeline/src/mask.rs#L2919), [`core/dr-pipeline/src/mask.rs:988`](../../core/dr-pipeline/src/mask.rs#L988), [`core/dr-pipeline/src/sidecar.rs:1226`](../../core/dr-pipeline/src/sidecar.rs#L1226), [`core/dr-pipeline/tests/mask_sidecar.rs:1347`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1347), [`core/dr-pipeline/tests/mask_sidecar.rs:1373`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1373), [`ui/dr-ui/src/develop.rs:2930`](../../ui/dr-ui/src/develop.rs#L2930), [`ui/dr-ui/src/develop.rs:2991`](../../ui/dr-ui/src/develop.rs#L2991), [`ui/dr-ui/src/develop.rs:3010`](../../ui/dr-ui/src/develop.rs#L3010), [`ui/dr-ui/src/develop.rs:880`](../../ui/dr-ui/src/develop.rs#L880), [`ui/dr-ui/src/labels.rs:73`](../../ui/dr-ui/src/labels.rs#L73), [`ui/dr-ui/src/masks_ui.rs:1017`](../../ui/dr-ui/src/masks_ui.rs#L1017), [`ui/dr-ui/src/masks_ui.rs:1089`](../../ui/dr-ui/src/masks_ui.rs#L1089), [`ui/dr-ui/src/masks_ui.rs:1448`](../../ui/dr-ui/src/masks_ui.rs#L1448), [`ui/dr-ui/src/masks_ui.rs:270`](../../ui/dr-ui/src/masks_ui.rs#L270), [`ui/dr-ui/ui/masks.slint:150`](../../ui/dr-ui/ui/masks.slint#L150), [`ui/dr-ui/ui/masks.slint:175`](../../ui/dr-ui/ui/masks.slint#L175), [`ui/dr-ui/ui/masks.slint:337`](../../ui/dr-ui/ui/masks.slint#L337), [`ui/dr-ui/ui/masks.slint:406`](../../ui/dr-ui/ui/masks.slint#L406), [`ui/dr-ui/ui/masks.slint:770`](../../ui/dr-ui/ui/masks.slint#L770), [`ui/dr-ui/ui/masks.slint:855`](../../ui/dr-ui/ui/masks.slint#L855) | -| FR-DEV-19b | [`ui/dr-ui/src/develop.rs:3091`](../../ui/dr-ui/src/develop.rs#L3091), [`ui/dr-ui/src/develop.rs:3634`](../../ui/dr-ui/src/develop.rs#L3634), [`ui/dr-ui/src/develop.rs:889`](../../ui/dr-ui/src/develop.rs#L889), [`ui/dr-ui/src/develop.rs:896`](../../ui/dr-ui/src/develop.rs#L896), [`ui/dr-ui/src/labels.rs:67`](../../ui/dr-ui/src/labels.rs#L67), [`ui/dr-ui/src/masks_ui.rs:1122`](../../ui/dr-ui/src/masks_ui.rs#L1122), [`ui/dr-ui/src/masks_ui.rs:1341`](../../ui/dr-ui/src/masks_ui.rs#L1341), [`ui/dr-ui/src/masks_ui.rs:1369`](../../ui/dr-ui/src/masks_ui.rs#L1369), [`ui/dr-ui/src/masks_ui.rs:1712`](../../ui/dr-ui/src/masks_ui.rs#L1712), [`ui/dr-ui/src/masks_ui.rs:858`](../../ui/dr-ui/src/masks_ui.rs#L858), [`ui/dr-ui/ui/app.slint:1056`](../../ui/dr-ui/ui/app.slint#L1056), [`ui/dr-ui/ui/app.slint:2260`](../../ui/dr-ui/ui/app.slint#L2260), [`ui/dr-ui/ui/masks.slint:1126`](../../ui/dr-ui/ui/masks.slint#L1126), [`ui/dr-ui/ui/masks.slint:453`](../../ui/dr-ui/ui/masks.slint#L453), [`ui/dr-ui/ui/masks.slint:774`](../../ui/dr-ui/ui/masks.slint#L774), [`ui/dr-ui/ui/masks.slint:848`](../../ui/dr-ui/ui/masks.slint#L848), [`ui/dr-ui/ui/masks.slint:864`](../../ui/dr-ui/ui/masks.slint#L864) | -| FR-DEV-19c | [`core/dr-gpu/src/mask.rs:715`](../../core/dr-gpu/src/mask.rs#L715), [`core/dr-gpu/tests/local_adjustments.rs:830`](../../core/dr-gpu/tests/local_adjustments.rs#L830), [`core/dr-gpu/tests/local_adjustments.rs:887`](../../core/dr-gpu/tests/local_adjustments.rs#L887), [`core/dr-gpu/tests/local_adjustments.rs:922`](../../core/dr-gpu/tests/local_adjustments.rs#L922), [`core/dr-gpu/tests/local_adjustments.rs:967`](../../core/dr-gpu/tests/local_adjustments.rs#L967), [`core/dr-pipeline/src/graph.rs:893`](../../core/dr-pipeline/src/graph.rs#L893), [`core/dr-pipeline/src/mask.rs:1796`](../../core/dr-pipeline/src/mask.rs#L1796), [`core/dr-pipeline/src/mask.rs:1822`](../../core/dr-pipeline/src/mask.rs#L1822), [`core/dr-pipeline/src/mask.rs:1837`](../../core/dr-pipeline/src/mask.rs#L1837), [`core/dr-pipeline/src/mask.rs:1957`](../../core/dr-pipeline/src/mask.rs#L1957), [`core/dr-pipeline/src/mask.rs:2005`](../../core/dr-pipeline/src/mask.rs#L2005), [`core/dr-pipeline/src/mask.rs:2149`](../../core/dr-pipeline/src/mask.rs#L2149), [`core/dr-pipeline/src/mask.rs:2386`](../../core/dr-pipeline/src/mask.rs#L2386), [`core/dr-pipeline/src/operation.rs:1748`](../../core/dr-pipeline/src/operation.rs#L1748), [`core/dr-pipeline/src/operation.rs:588`](../../core/dr-pipeline/src/operation.rs#L588), [`core/dr-pipeline/src/operation.rs:863`](../../core/dr-pipeline/src/operation.rs#L863), [`ui/dr-ui/src/develop.rs:2035`](../../ui/dr-ui/src/develop.rs#L2035), [`ui/dr-ui/src/develop.rs:2295`](../../ui/dr-ui/src/develop.rs#L2295), [`ui/dr-ui/src/develop.rs:2542`](../../ui/dr-ui/src/develop.rs#L2542), [`ui/dr-ui/src/develop.rs:2675`](../../ui/dr-ui/src/develop.rs#L2675), [`ui/dr-ui/src/develop.rs:4068`](../../ui/dr-ui/src/develop.rs#L4068), [`ui/dr-ui/src/develop.rs:686`](../../ui/dr-ui/src/develop.rs#L686), [`ui/dr-ui/src/develop.rs:695`](../../ui/dr-ui/src/develop.rs#L695), [`ui/dr-ui/src/develop.rs:7249`](../../ui/dr-ui/src/develop.rs#L7249), [`ui/dr-ui/src/develop.rs:7285`](../../ui/dr-ui/src/develop.rs#L7285), [`ui/dr-ui/src/develop.rs:915`](../../ui/dr-ui/src/develop.rs#L915), [`ui/dr-ui/src/develop.rs:924`](../../ui/dr-ui/src/develop.rs#L924), [`ui/dr-ui/src/masks_ui.rs:1341`](../../ui/dr-ui/src/masks_ui.rs#L1341), [`ui/dr-ui/src/masks_ui.rs:152`](../../ui/dr-ui/src/masks_ui.rs#L152), [`ui/dr-ui/src/masks_ui.rs:1712`](../../ui/dr-ui/src/masks_ui.rs#L1712), [`ui/dr-ui/src/masks_ui.rs:291`](../../ui/dr-ui/src/masks_ui.rs#L291), [`ui/dr-ui/src/masks_ui.rs:317`](../../ui/dr-ui/src/masks_ui.rs#L317), [`ui/dr-ui/src/masks_ui.rs:634`](../../ui/dr-ui/src/masks_ui.rs#L634), [`ui/dr-ui/src/masks_ui.rs:940`](../../ui/dr-ui/src/masks_ui.rs#L940), [`ui/dr-ui/src/masks_ui.rs:968`](../../ui/dr-ui/src/masks_ui.rs#L968), [`ui/dr-ui/ui/icons.slint:339`](../../ui/dr-ui/ui/icons.slint#L339), [`ui/dr-ui/ui/masks.slint:1091`](../../ui/dr-ui/ui/masks.slint#L1091), [`ui/dr-ui/ui/masks.slint:183`](../../ui/dr-ui/ui/masks.slint#L183), [`ui/dr-ui/ui/masks.slint:292`](../../ui/dr-ui/ui/masks.slint#L292), [`ui/dr-ui/ui/masks.slint:517`](../../ui/dr-ui/ui/masks.slint#L517), [`ui/dr-ui/ui/masks.slint:70`](../../ui/dr-ui/ui/masks.slint#L70), [`ui/dr-ui/ui/masks.slint:782`](../../ui/dr-ui/ui/masks.slint#L782), [`ui/dr-ui/ui/masks.slint:850`](../../ui/dr-ui/ui/masks.slint#L850), [`ui/dr-ui/ui/masks.slint:938`](../../ui/dr-ui/ui/masks.slint#L938) | +| FR-DEV-19a | [`core/dr-gpu/src/mask.rs:761`](../../core/dr-gpu/src/mask.rs#L761), [`core/dr-pipeline/src/mask.rs:1588`](../../core/dr-pipeline/src/mask.rs#L1588), [`core/dr-pipeline/src/mask.rs:2919`](../../core/dr-pipeline/src/mask.rs#L2919), [`core/dr-pipeline/src/mask.rs:988`](../../core/dr-pipeline/src/mask.rs#L988), [`core/dr-pipeline/src/sidecar.rs:1226`](../../core/dr-pipeline/src/sidecar.rs#L1226), [`core/dr-pipeline/tests/mask_sidecar.rs:1347`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1347), [`core/dr-pipeline/tests/mask_sidecar.rs:1373`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1373), [`ui/dr-ui/src/develop/masks.rs:431`](../../ui/dr-ui/src/develop/masks.rs#L431), [`ui/dr-ui/src/develop/masks.rs:492`](../../ui/dr-ui/src/develop/masks.rs#L492), [`ui/dr-ui/src/develop/masks.rs:511`](../../ui/dr-ui/src/develop/masks.rs#L511), [`ui/dr-ui/src/develop/session.rs:188`](../../ui/dr-ui/src/develop/session.rs#L188), [`ui/dr-ui/src/labels.rs:73`](../../ui/dr-ui/src/labels.rs#L73), [`ui/dr-ui/src/masks_ui.rs:1017`](../../ui/dr-ui/src/masks_ui.rs#L1017), [`ui/dr-ui/src/masks_ui.rs:1089`](../../ui/dr-ui/src/masks_ui.rs#L1089), [`ui/dr-ui/src/masks_ui.rs:1448`](../../ui/dr-ui/src/masks_ui.rs#L1448), [`ui/dr-ui/src/masks_ui.rs:270`](../../ui/dr-ui/src/masks_ui.rs#L270), [`ui/dr-ui/ui/masks.slint:150`](../../ui/dr-ui/ui/masks.slint#L150), [`ui/dr-ui/ui/masks.slint:175`](../../ui/dr-ui/ui/masks.slint#L175), [`ui/dr-ui/ui/masks.slint:337`](../../ui/dr-ui/ui/masks.slint#L337), [`ui/dr-ui/ui/masks.slint:406`](../../ui/dr-ui/ui/masks.slint#L406), [`ui/dr-ui/ui/masks.slint:770`](../../ui/dr-ui/ui/masks.slint#L770), [`ui/dr-ui/ui/masks.slint:855`](../../ui/dr-ui/ui/masks.slint#L855) | +| FR-DEV-19b | [`ui/dr-ui/src/develop/mask_ops.rs:606`](../../ui/dr-ui/src/develop/mask_ops.rs#L606), [`ui/dr-ui/src/develop/masks.rs:592`](../../ui/dr-ui/src/develop/masks.rs#L592), [`ui/dr-ui/src/develop/session.rs:197`](../../ui/dr-ui/src/develop/session.rs#L197), [`ui/dr-ui/src/develop/session.rs:204`](../../ui/dr-ui/src/develop/session.rs#L204), [`ui/dr-ui/src/labels.rs:67`](../../ui/dr-ui/src/labels.rs#L67), [`ui/dr-ui/src/masks_ui.rs:1122`](../../ui/dr-ui/src/masks_ui.rs#L1122), [`ui/dr-ui/src/masks_ui.rs:1341`](../../ui/dr-ui/src/masks_ui.rs#L1341), [`ui/dr-ui/src/masks_ui.rs:1369`](../../ui/dr-ui/src/masks_ui.rs#L1369), [`ui/dr-ui/src/masks_ui.rs:1712`](../../ui/dr-ui/src/masks_ui.rs#L1712), [`ui/dr-ui/src/masks_ui.rs:858`](../../ui/dr-ui/src/masks_ui.rs#L858), [`ui/dr-ui/ui/app.slint:1056`](../../ui/dr-ui/ui/app.slint#L1056), [`ui/dr-ui/ui/app.slint:2260`](../../ui/dr-ui/ui/app.slint#L2260), [`ui/dr-ui/ui/masks.slint:1126`](../../ui/dr-ui/ui/masks.slint#L1126), [`ui/dr-ui/ui/masks.slint:453`](../../ui/dr-ui/ui/masks.slint#L453), [`ui/dr-ui/ui/masks.slint:774`](../../ui/dr-ui/ui/masks.slint#L774), [`ui/dr-ui/ui/masks.slint:848`](../../ui/dr-ui/ui/masks.slint#L848), [`ui/dr-ui/ui/masks.slint:864`](../../ui/dr-ui/ui/masks.slint#L864) | +| FR-DEV-19c | [`core/dr-gpu/src/mask.rs:715`](../../core/dr-gpu/src/mask.rs#L715), [`core/dr-gpu/tests/local_adjustments.rs:830`](../../core/dr-gpu/tests/local_adjustments.rs#L830), [`core/dr-gpu/tests/local_adjustments.rs:887`](../../core/dr-gpu/tests/local_adjustments.rs#L887), [`core/dr-gpu/tests/local_adjustments.rs:922`](../../core/dr-gpu/tests/local_adjustments.rs#L922), [`core/dr-gpu/tests/local_adjustments.rs:967`](../../core/dr-gpu/tests/local_adjustments.rs#L967), [`core/dr-pipeline/src/graph.rs:893`](../../core/dr-pipeline/src/graph.rs#L893), [`core/dr-pipeline/src/mask.rs:1796`](../../core/dr-pipeline/src/mask.rs#L1796), [`core/dr-pipeline/src/mask.rs:1822`](../../core/dr-pipeline/src/mask.rs#L1822), [`core/dr-pipeline/src/mask.rs:1837`](../../core/dr-pipeline/src/mask.rs#L1837), [`core/dr-pipeline/src/mask.rs:1957`](../../core/dr-pipeline/src/mask.rs#L1957), [`core/dr-pipeline/src/mask.rs:2005`](../../core/dr-pipeline/src/mask.rs#L2005), [`core/dr-pipeline/src/mask.rs:2149`](../../core/dr-pipeline/src/mask.rs#L2149), [`core/dr-pipeline/src/mask.rs:2386`](../../core/dr-pipeline/src/mask.rs#L2386), [`core/dr-pipeline/src/operation.rs:1748`](../../core/dr-pipeline/src/operation.rs#L1748), [`core/dr-pipeline/src/operation.rs:588`](../../core/dr-pipeline/src/operation.rs#L588), [`core/dr-pipeline/src/operation.rs:863`](../../core/dr-pipeline/src/operation.rs#L863), [`ui/dr-ui/src/develop/mask_ops.rs:1199`](../../ui/dr-ui/src/develop/mask_ops.rs#L1199), [`ui/dr-ui/src/develop/mask_ops.rs:1235`](../../ui/dr-ui/src/develop/mask_ops.rs#L1235), [`ui/dr-ui/src/develop/mask_ops.rs:318`](../../ui/dr-ui/src/develop/mask_ops.rs#L318), [`ui/dr-ui/src/develop/mask_ops.rs:58`](../../ui/dr-ui/src/develop/mask_ops.rs#L58), [`ui/dr-ui/src/develop/masks.rs:12`](../../ui/dr-ui/src/develop/masks.rs#L12), [`ui/dr-ui/src/develop/masks.rs:176`](../../ui/dr-ui/src/develop/masks.rs#L176), [`ui/dr-ui/src/develop/masks.rs:21`](../../ui/dr-ui/src/develop/masks.rs#L21), [`ui/dr-ui/src/develop/masks.rs:43`](../../ui/dr-ui/src/develop/masks.rs#L43), [`ui/dr-ui/src/develop/render.rs:178`](../../ui/dr-ui/src/develop/render.rs#L178), [`ui/dr-ui/src/develop/session.rs:223`](../../ui/dr-ui/src/develop/session.rs#L223), [`ui/dr-ui/src/develop/session.rs:232`](../../ui/dr-ui/src/develop/session.rs#L232), [`ui/dr-ui/src/masks_ui.rs:1341`](../../ui/dr-ui/src/masks_ui.rs#L1341), [`ui/dr-ui/src/masks_ui.rs:152`](../../ui/dr-ui/src/masks_ui.rs#L152), [`ui/dr-ui/src/masks_ui.rs:1712`](../../ui/dr-ui/src/masks_ui.rs#L1712), [`ui/dr-ui/src/masks_ui.rs:291`](../../ui/dr-ui/src/masks_ui.rs#L291), [`ui/dr-ui/src/masks_ui.rs:317`](../../ui/dr-ui/src/masks_ui.rs#L317), [`ui/dr-ui/src/masks_ui.rs:634`](../../ui/dr-ui/src/masks_ui.rs#L634), [`ui/dr-ui/src/masks_ui.rs:940`](../../ui/dr-ui/src/masks_ui.rs#L940), [`ui/dr-ui/src/masks_ui.rs:968`](../../ui/dr-ui/src/masks_ui.rs#L968), [`ui/dr-ui/ui/icons.slint:339`](../../ui/dr-ui/ui/icons.slint#L339), [`ui/dr-ui/ui/masks.slint:1091`](../../ui/dr-ui/ui/masks.slint#L1091), [`ui/dr-ui/ui/masks.slint:183`](../../ui/dr-ui/ui/masks.slint#L183), [`ui/dr-ui/ui/masks.slint:292`](../../ui/dr-ui/ui/masks.slint#L292), [`ui/dr-ui/ui/masks.slint:517`](../../ui/dr-ui/ui/masks.slint#L517), [`ui/dr-ui/ui/masks.slint:70`](../../ui/dr-ui/ui/masks.slint#L70), [`ui/dr-ui/ui/masks.slint:782`](../../ui/dr-ui/ui/masks.slint#L782), [`ui/dr-ui/ui/masks.slint:850`](../../ui/dr-ui/ui/masks.slint#L850), [`ui/dr-ui/ui/masks.slint:938`](../../ui/dr-ui/ui/masks.slint#L938) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:403`](../../core/dr-pipeline/src/operation.rs#L403) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2443`](../../core/dr-gpu/src/adjust.rs#L2443), [`core/dr-gpu/src/adjust.rs:679`](../../core/dr-gpu/src/adjust.rs#L679), [`core/dr-gpu/src/adjust.rs:798`](../../core/dr-gpu/src/adjust.rs#L798), [`core/dr-gpu/src/adjust.rs:84`](../../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/coverage.rs:122`](../../core/dr-pipeline/src/coverage.rs#L122), [`core/dr-pipeline/src/coverage.rs:1`](../../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:498`](../../core/dr-pipeline/src/framing.rs#L498), [`core/dr-pipeline/src/framing.rs:753`](../../core/dr-pipeline/src/framing.rs#L753), [`core/dr-pipeline/src/graph.rs:1348`](../../core/dr-pipeline/src/graph.rs#L1348), [`core/dr-pipeline/src/graph.rs:1379`](../../core/dr-pipeline/src/graph.rs#L1379), [`core/dr-pipeline/src/graph.rs:1402`](../../core/dr-pipeline/src/graph.rs#L1402), [`core/dr-pipeline/src/graph.rs:1440`](../../core/dr-pipeline/src/graph.rs#L1440), [`core/dr-pipeline/src/graph.rs:1471`](../../core/dr-pipeline/src/graph.rs#L1471), [`core/dr-pipeline/src/graph.rs:225`](../../core/dr-pipeline/src/graph.rs#L225), [`core/dr-pipeline/src/graph.rs:306`](../../core/dr-pipeline/src/graph.rs#L306), [`core/dr-pipeline/src/graph.rs:344`](../../core/dr-pipeline/src/graph.rs#L344), [`core/dr-pipeline/src/graph.rs:350`](../../core/dr-pipeline/src/graph.rs#L350), [`core/dr-pipeline/src/graph.rs:860`](../../core/dr-pipeline/src/graph.rs#L860), [`core/dr-pipeline/src/graph.rs:952`](../../core/dr-pipeline/src/graph.rs#L952), [`core/dr-pipeline/src/lens.rs:93`](../../core/dr-pipeline/src/lens.rs#L93), [`core/dr-pipeline/src/mask.rs:205`](../../core/dr-pipeline/src/mask.rs#L205), [`core/dr-pipeline/src/mask.rs:558`](../../core/dr-pipeline/src/mask.rs#L558), [`core/dr-pipeline/src/neutral.rs:1`](../../core/dr-pipeline/src/neutral.rs#L1), [`core/dr-pipeline/src/neutral.rs:306`](../../core/dr-pipeline/src/neutral.rs#L306), [`core/dr-pipeline/src/neutral.rs:322`](../../core/dr-pipeline/src/neutral.rs#L322), [`core/dr-pipeline/src/neutral.rs:338`](../../core/dr-pipeline/src/neutral.rs#L338), [`core/dr-pipeline/src/neutral.rs:360`](../../core/dr-pipeline/src/neutral.rs#L360), [`core/dr-pipeline/src/neutral.rs:81`](../../core/dr-pipeline/src/neutral.rs#L81), [`core/dr-pipeline/src/operation.rs:330`](../../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:565`](../../core/dr-pipeline/src/operation.rs#L565), [`core/dr-pipeline/src/operation.rs:637`](../../core/dr-pipeline/src/operation.rs#L637), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/preset.rs:751`](../../core/dr-pipeline/src/preset.rs#L751), [`core/dr-pipeline/src/sidecar.rs:1400`](../../core/dr-pipeline/src/sidecar.rs#L1400), [`core/dr-pipeline/src/sidecar.rs:192`](../../core/dr-pipeline/src/sidecar.rs#L192), [`core/dr-pipeline/src/sidecar.rs:2232`](../../core/dr-pipeline/src/sidecar.rs#L2232), [`core/dr-pipeline/src/sidecar.rs:2312`](../../core/dr-pipeline/src/sidecar.rs#L2312), [`core/dr-pipeline/tests/tone_curve.rs:1`](../../core/dr-pipeline/tests/tone_curve.rs#L1), [`core/dr-segment/src/refine.rs:937`](../../core/dr-segment/src/refine.rs#L937), [`ui/dr-ui/src/develop.rs:103`](../../ui/dr-ui/src/develop.rs#L103), [`ui/dr-ui/src/develop.rs:1318`](../../ui/dr-ui/src/develop.rs#L1318), [`ui/dr-ui/src/develop.rs:1407`](../../ui/dr-ui/src/develop.rs#L1407), [`ui/dr-ui/src/develop.rs:1443`](../../ui/dr-ui/src/develop.rs#L1443), [`ui/dr-ui/src/develop.rs:165`](../../ui/dr-ui/src/develop.rs#L165), [`ui/dr-ui/src/develop.rs:1714`](../../ui/dr-ui/src/develop.rs#L1714), [`ui/dr-ui/src/develop.rs:2155`](../../ui/dr-ui/src/develop.rs#L2155), [`ui/dr-ui/src/develop.rs:2398`](../../ui/dr-ui/src/develop.rs#L2398), [`ui/dr-ui/src/develop.rs:2416`](../../ui/dr-ui/src/develop.rs#L2416), [`ui/dr-ui/src/develop.rs:2430`](../../ui/dr-ui/src/develop.rs#L2430), [`ui/dr-ui/src/develop.rs:2452`](../../ui/dr-ui/src/develop.rs#L2452), [`ui/dr-ui/src/develop.rs:2761`](../../ui/dr-ui/src/develop.rs#L2761), [`ui/dr-ui/src/develop.rs:2859`](../../ui/dr-ui/src/develop.rs#L2859), [`ui/dr-ui/src/develop.rs:328`](../../ui/dr-ui/src/develop.rs#L328), [`ui/dr-ui/src/develop.rs:365`](../../ui/dr-ui/src/develop.rs#L365), [`ui/dr-ui/src/develop.rs:4409`](../../ui/dr-ui/src/develop.rs#L4409), [`ui/dr-ui/src/develop.rs:4598`](../../ui/dr-ui/src/develop.rs#L4598), [`ui/dr-ui/src/develop.rs:4658`](../../ui/dr-ui/src/develop.rs#L4658), [`ui/dr-ui/src/develop.rs:5063`](../../ui/dr-ui/src/develop.rs#L5063), [`ui/dr-ui/src/develop.rs:5093`](../../ui/dr-ui/src/develop.rs#L5093), [`ui/dr-ui/src/develop.rs:5159`](../../ui/dr-ui/src/develop.rs#L5159), [`ui/dr-ui/src/develop.rs:5173`](../../ui/dr-ui/src/develop.rs#L5173), [`ui/dr-ui/src/develop.rs:5375`](../../ui/dr-ui/src/develop.rs#L5375), [`ui/dr-ui/src/develop.rs:5463`](../../ui/dr-ui/src/develop.rs#L5463), [`ui/dr-ui/src/develop.rs:588`](../../ui/dr-ui/src/develop.rs#L588), [`ui/dr-ui/src/develop.rs:6409`](../../ui/dr-ui/src/develop.rs#L6409), [`ui/dr-ui/src/develop.rs:6482`](../../ui/dr-ui/src/develop.rs#L6482), [`ui/dr-ui/src/develop.rs:649`](../../ui/dr-ui/src/develop.rs#L649), [`ui/dr-ui/src/develop.rs:6675`](../../ui/dr-ui/src/develop.rs#L6675), [`ui/dr-ui/src/develop.rs:6706`](../../ui/dr-ui/src/develop.rs#L6706), [`ui/dr-ui/src/develop.rs:6789`](../../ui/dr-ui/src/develop.rs#L6789), [`ui/dr-ui/src/develop.rs:6851`](../../ui/dr-ui/src/develop.rs#L6851), [`ui/dr-ui/src/develop.rs:7057`](../../ui/dr-ui/src/develop.rs#L7057), [`ui/dr-ui/src/develop.rs:7213`](../../ui/dr-ui/src/develop.rs#L7213), [`ui/dr-ui/src/develop.rs:7390`](../../ui/dr-ui/src/develop.rs#L7390), [`ui/dr-ui/src/develop.rs:7434`](../../ui/dr-ui/src/develop.rs#L7434), [`ui/dr-ui/src/develop.rs:7484`](../../ui/dr-ui/src/develop.rs#L7484), [`ui/dr-ui/src/develop.rs:7909`](../../ui/dr-ui/src/develop.rs#L7909), [`ui/dr-ui/src/develop.rs:850`](../../ui/dr-ui/src/develop.rs#L850), [`ui/dr-ui/src/develop.rs:8637`](../../ui/dr-ui/src/develop.rs#L8637), [`ui/dr-ui/src/develop.rs:943`](../../ui/dr-ui/src/develop.rs#L943), [`ui/dr-ui/src/develop.rs:972`](../../ui/dr-ui/src/develop.rs#L972), [`ui/dr-ui/src/labels.rs:35`](../../ui/dr-ui/src/labels.rs#L35), [`ui/dr-ui/src/lib.rs:2021`](../../ui/dr-ui/src/lib.rs#L2021), [`ui/dr-ui/src/lib.rs:2901`](../../ui/dr-ui/src/lib.rs#L2901), [`ui/dr-ui/src/lib.rs:3108`](../../ui/dr-ui/src/lib.rs#L3108), [`ui/dr-ui/src/lib.rs:3239`](../../ui/dr-ui/src/lib.rs#L3239), [`ui/dr-ui/src/lib.rs:3444`](../../ui/dr-ui/src/lib.rs#L3444), [`ui/dr-ui/src/lib.rs:3508`](../../ui/dr-ui/src/lib.rs#L3508), [`ui/dr-ui/src/lib.rs:3562`](../../ui/dr-ui/src/lib.rs#L3562), [`ui/dr-ui/src/lib.rs:419`](../../ui/dr-ui/src/lib.rs#L419), [`ui/dr-ui/src/lib.rs:448`](../../ui/dr-ui/src/lib.rs#L448), [`ui/dr-ui/src/lib.rs:554`](../../ui/dr-ui/src/lib.rs#L554), [`ui/dr-ui/src/lib.rs:578`](../../ui/dr-ui/src/lib.rs#L578), [`ui/dr-ui/src/library.rs:616`](../../ui/dr-ui/src/library.rs#L616), [`ui/dr-ui/src/masks_ui.rs:1471`](../../ui/dr-ui/src/masks_ui.rs#L1471), [`ui/dr-ui/src/masks_ui.rs:1585`](../../ui/dr-ui/src/masks_ui.rs#L1585), [`ui/dr-ui/src/masks_ui.rs:339`](../../ui/dr-ui/src/masks_ui.rs#L339), [`ui/dr-ui/src/masks_ui.rs:44`](../../ui/dr-ui/src/masks_ui.rs#L44), [`ui/dr-ui/src/presets.rs:414`](../../ui/dr-ui/src/presets.rs#L414), [`ui/dr-ui/src/segmentation.rs:117`](../../ui/dr-ui/src/segmentation.rs#L117), [`ui/dr-ui/src/segmentation.rs:353`](../../ui/dr-ui/src/segmentation.rs#L353), [`ui/dr-ui/src/segmentation.rs:369`](../../ui/dr-ui/src/segmentation.rs#L369), [`ui/dr-ui/src/segmentation.rs:499`](../../ui/dr-ui/src/segmentation.rs#L499), [`ui/dr-ui/src/segmentation.rs:527`](../../ui/dr-ui/src/segmentation.rs#L527), [`ui/dr-ui/ui/adjust.slint:1019`](../../ui/dr-ui/ui/adjust.slint#L1019), [`ui/dr-ui/ui/adjust.slint:126`](../../ui/dr-ui/ui/adjust.slint#L126), [`ui/dr-ui/ui/adjust.slint:1496`](../../ui/dr-ui/ui/adjust.slint#L1496), [`ui/dr-ui/ui/adjust.slint:171`](../../ui/dr-ui/ui/adjust.slint#L171), [`ui/dr-ui/ui/adjust.slint:46`](../../ui/dr-ui/ui/adjust.slint#L46), [`ui/dr-ui/ui/adjust.slint:624`](../../ui/dr-ui/ui/adjust.slint#L624), [`ui/dr-ui/ui/adjust.slint:650`](../../ui/dr-ui/ui/adjust.slint#L650), [`ui/dr-ui/ui/adjust.slint:669`](../../ui/dr-ui/ui/adjust.slint#L669), [`ui/dr-ui/ui/adjust.slint:790`](../../ui/dr-ui/ui/adjust.slint#L790), [`ui/dr-ui/ui/adjust.slint:947`](../../ui/dr-ui/ui/adjust.slint#L947), [`ui/dr-ui/ui/app.slint:149`](../../ui/dr-ui/ui/app.slint#L149), [`ui/dr-ui/ui/app.slint:2325`](../../ui/dr-ui/ui/app.slint#L2325), [`ui/dr-ui/ui/app.slint:2385`](../../ui/dr-ui/ui/app.slint#L2385), [`ui/dr-ui/ui/develop.slint:223`](../../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/masks.slint:1216`](../../ui/dr-ui/ui/masks.slint#L1216), [`ui/dr-ui/ui/masks.slint:753`](../../ui/dr-ui/ui/masks.slint#L753), [`ui/dr-ui/ui/masks.slint:812`](../../ui/dr-ui/ui/masks.slint#L812) | -| FR-DEV-3a | [`core/dr-pipeline/ops/exposure.yaml:1`](../../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:212`](../../core/dr-pipeline/src/descriptor.rs#L212), [`core/dr-pipeline/src/descriptor.rs:252`](../../core/dr-pipeline/src/descriptor.rs#L252), [`core/dr-pipeline/src/descriptor.rs:276`](../../core/dr-pipeline/src/descriptor.rs#L276), [`core/dr-pipeline/src/descriptor.rs:331`](../../core/dr-pipeline/src/descriptor.rs#L331), [`core/dr-pipeline/src/framing.rs:395`](../../core/dr-pipeline/src/framing.rs#L395), [`core/dr-pipeline/src/graph.rs:25`](../../core/dr-pipeline/src/graph.rs#L25), [`core/dr-pipeline/src/graph.rs:393`](../../core/dr-pipeline/src/graph.rs#L393), [`core/dr-pipeline/src/graph.rs:47`](../../core/dr-pipeline/src/graph.rs#L47), [`core/dr-pipeline/src/graph.rs:534`](../../core/dr-pipeline/src/graph.rs#L534), [`core/dr-pipeline/src/graph.rs:60`](../../core/dr-pipeline/src/graph.rs#L60), [`core/dr-pipeline/src/mask.rs:1699`](../../core/dr-pipeline/src/mask.rs#L1699), [`core/dr-pipeline/src/neutral.rs:1`](../../core/dr-pipeline/src/neutral.rs#L1), [`core/dr-pipeline/src/neutral.rs:378`](../../core/dr-pipeline/src/neutral.rs#L378), [`core/dr-pipeline/src/operation.rs:232`](../../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1330`](../../ui/dr-ui/src/develop.rs#L1330), [`ui/dr-ui/src/develop.rs:1609`](../../ui/dr-ui/src/develop.rs#L1609), [`ui/dr-ui/src/develop.rs:7909`](../../ui/dr-ui/src/develop.rs#L7909), [`ui/dr-ui/src/develop.rs:8107`](../../ui/dr-ui/src/develop.rs#L8107), [`ui/dr-ui/src/lib.rs:879`](../../ui/dr-ui/src/lib.rs#L879), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../../ui/dr-ui/tests/ui_names_no_operation.rs#L1), [`ui/dr-ui/ui/app.slint:2569`](../../ui/dr-ui/ui/app.slint#L2569) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2443`](../../core/dr-gpu/src/adjust.rs#L2443), [`core/dr-gpu/src/adjust.rs:679`](../../core/dr-gpu/src/adjust.rs#L679), [`core/dr-gpu/src/adjust.rs:798`](../../core/dr-gpu/src/adjust.rs#L798), [`core/dr-gpu/src/adjust.rs:84`](../../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/coverage.rs:122`](../../core/dr-pipeline/src/coverage.rs#L122), [`core/dr-pipeline/src/coverage.rs:1`](../../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:498`](../../core/dr-pipeline/src/framing.rs#L498), [`core/dr-pipeline/src/framing.rs:753`](../../core/dr-pipeline/src/framing.rs#L753), [`core/dr-pipeline/src/graph.rs:1348`](../../core/dr-pipeline/src/graph.rs#L1348), [`core/dr-pipeline/src/graph.rs:1379`](../../core/dr-pipeline/src/graph.rs#L1379), [`core/dr-pipeline/src/graph.rs:1402`](../../core/dr-pipeline/src/graph.rs#L1402), [`core/dr-pipeline/src/graph.rs:1440`](../../core/dr-pipeline/src/graph.rs#L1440), [`core/dr-pipeline/src/graph.rs:1471`](../../core/dr-pipeline/src/graph.rs#L1471), [`core/dr-pipeline/src/graph.rs:225`](../../core/dr-pipeline/src/graph.rs#L225), [`core/dr-pipeline/src/graph.rs:306`](../../core/dr-pipeline/src/graph.rs#L306), [`core/dr-pipeline/src/graph.rs:344`](../../core/dr-pipeline/src/graph.rs#L344), [`core/dr-pipeline/src/graph.rs:350`](../../core/dr-pipeline/src/graph.rs#L350), [`core/dr-pipeline/src/graph.rs:860`](../../core/dr-pipeline/src/graph.rs#L860), [`core/dr-pipeline/src/graph.rs:952`](../../core/dr-pipeline/src/graph.rs#L952), [`core/dr-pipeline/src/lens.rs:93`](../../core/dr-pipeline/src/lens.rs#L93), [`core/dr-pipeline/src/mask.rs:205`](../../core/dr-pipeline/src/mask.rs#L205), [`core/dr-pipeline/src/mask.rs:558`](../../core/dr-pipeline/src/mask.rs#L558), [`core/dr-pipeline/src/neutral.rs:1`](../../core/dr-pipeline/src/neutral.rs#L1), [`core/dr-pipeline/src/neutral.rs:306`](../../core/dr-pipeline/src/neutral.rs#L306), [`core/dr-pipeline/src/neutral.rs:322`](../../core/dr-pipeline/src/neutral.rs#L322), [`core/dr-pipeline/src/neutral.rs:338`](../../core/dr-pipeline/src/neutral.rs#L338), [`core/dr-pipeline/src/neutral.rs:360`](../../core/dr-pipeline/src/neutral.rs#L360), [`core/dr-pipeline/src/neutral.rs:81`](../../core/dr-pipeline/src/neutral.rs#L81), [`core/dr-pipeline/src/operation.rs:330`](../../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:565`](../../core/dr-pipeline/src/operation.rs#L565), [`core/dr-pipeline/src/operation.rs:637`](../../core/dr-pipeline/src/operation.rs#L637), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/preset.rs:751`](../../core/dr-pipeline/src/preset.rs#L751), [`core/dr-pipeline/src/sidecar.rs:1400`](../../core/dr-pipeline/src/sidecar.rs#L1400), [`core/dr-pipeline/src/sidecar.rs:192`](../../core/dr-pipeline/src/sidecar.rs#L192), [`core/dr-pipeline/src/sidecar.rs:2232`](../../core/dr-pipeline/src/sidecar.rs#L2232), [`core/dr-pipeline/src/sidecar.rs:2312`](../../core/dr-pipeline/src/sidecar.rs#L2312), [`core/dr-pipeline/tests/tone_curve.rs:1`](../../core/dr-pipeline/tests/tone_curve.rs#L1), [`core/dr-segment/src/refine.rs:937`](../../core/dr-segment/src/refine.rs#L937), [`ui/dr-ui/src/develop/curves.rs:163`](../../ui/dr-ui/src/develop/curves.rs#L163), [`ui/dr-ui/src/develop/framing.rs:10`](../../ui/dr-ui/src/develop/framing.rs#L10), [`ui/dr-ui/src/develop/framing.rs:182`](../../ui/dr-ui/src/develop/framing.rs#L182), [`ui/dr-ui/src/develop/framing.rs:212`](../../ui/dr-ui/src/develop/framing.rs#L212), [`ui/dr-ui/src/develop/framing.rs:278`](../../ui/dr-ui/src/develop/framing.rs#L278), [`ui/dr-ui/src/develop/framing.rs:292`](../../ui/dr-ui/src/develop/framing.rs#L292), [`ui/dr-ui/src/develop/framing.rs:494`](../../ui/dr-ui/src/develop/framing.rs#L494), [`ui/dr-ui/src/develop/framing.rs:71`](../../ui/dr-ui/src/develop/framing.rs#L71), [`ui/dr-ui/src/develop/mask_ops.rs:1076`](../../ui/dr-ui/src/develop/mask_ops.rs#L1076), [`ui/dr-ui/src/develop/mask_ops.rs:1340`](../../ui/dr-ui/src/develop/mask_ops.rs#L1340), [`ui/dr-ui/src/develop/mask_ops.rs:178`](../../ui/dr-ui/src/develop/mask_ops.rs#L178), [`ui/dr-ui/src/develop/mask_ops.rs:969`](../../ui/dr-ui/src/develop/mask_ops.rs#L969), [`ui/dr-ui/src/develop/masks.rs:262`](../../ui/dr-ui/src/develop/masks.rs#L262), [`ui/dr-ui/src/develop/masks.rs:360`](../../ui/dr-ui/src/develop/masks.rs#L360), [`ui/dr-ui/src/develop/masks.rs:740`](../../ui/dr-ui/src/develop/masks.rs#L740), [`ui/dr-ui/src/develop/masks.rs:770`](../../ui/dr-ui/src/develop/masks.rs#L770), [`ui/dr-ui/src/develop/render.rs:1039`](../../ui/dr-ui/src/develop/render.rs#L1039), [`ui/dr-ui/src/develop/rows.rs:106`](../../ui/dr-ui/src/develop/rows.rs#L106), [`ui/dr-ui/src/develop/rows.rs:195`](../../ui/dr-ui/src/develop/rows.rs#L195), [`ui/dr-ui/src/develop/rows.rs:231`](../../ui/dr-ui/src/develop/rows.rs#L231), [`ui/dr-ui/src/develop/rows.rs:679`](../../ui/dr-ui/src/develop/rows.rs#L679), [`ui/dr-ui/src/develop/segmentation.rs:139`](../../ui/dr-ui/src/develop/segmentation.rs#L139), [`ui/dr-ui/src/develop/segmentation.rs:302`](../../ui/dr-ui/src/develop/segmentation.rs#L302), [`ui/dr-ui/src/develop/segmentation.rs:339`](../../ui/dr-ui/src/develop/segmentation.rs#L339), [`ui/dr-ui/src/develop/segmentation.rs:572`](../../ui/dr-ui/src/develop/segmentation.rs#L572), [`ui/dr-ui/src/develop/segmentation.rs:590`](../../ui/dr-ui/src/develop/segmentation.rs#L590), [`ui/dr-ui/src/develop/segmentation.rs:604`](../../ui/dr-ui/src/develop/segmentation.rs#L604), [`ui/dr-ui/src/develop/segmentation.rs:626`](../../ui/dr-ui/src/develop/segmentation.rs#L626), [`ui/dr-ui/src/develop/segmentation.rs:77`](../../ui/dr-ui/src/develop/segmentation.rs#L77), [`ui/dr-ui/src/develop/segmentation.rs:806`](../../ui/dr-ui/src/develop/segmentation.rs#L806), [`ui/dr-ui/src/develop/session.rs:158`](../../ui/dr-ui/src/develop/session.rs#L158), [`ui/dr-ui/src/develop/session.rs:251`](../../ui/dr-ui/src/develop/session.rs#L251), [`ui/dr-ui/src/develop/session.rs:280`](../../ui/dr-ui/src/develop/session.rs#L280), [`ui/dr-ui/src/develop/session.rs:429`](../../ui/dr-ui/src/develop/session.rs#L429), [`ui/dr-ui/src/develop/session.rs:489`](../../ui/dr-ui/src/develop/session.rs#L489), [`ui/dr-ui/src/develop/session.rs:561`](../../ui/dr-ui/src/develop/session.rs#L561), [`ui/dr-ui/src/develop/session.rs:711`](../../ui/dr-ui/src/develop/session.rs#L711), [`ui/dr-ui/src/develop/white_balance.rs:11`](../../ui/dr-ui/src/develop/white_balance.rs#L11), [`ui/dr-ui/src/develop/white_balance.rs:162`](../../ui/dr-ui/src/develop/white_balance.rs#L162), [`ui/dr-ui/src/develop/white_balance.rs:193`](../../ui/dr-ui/src/develop/white_balance.rs#L193), [`ui/dr-ui/src/develop/white_balance.rs:276`](../../ui/dr-ui/src/develop/white_balance.rs#L276), [`ui/dr-ui/src/develop/white_balance.rs:338`](../../ui/dr-ui/src/develop/white_balance.rs#L338), [`ui/dr-ui/src/labels.rs:35`](../../ui/dr-ui/src/labels.rs#L35), [`ui/dr-ui/src/lib.rs:2021`](../../ui/dr-ui/src/lib.rs#L2021), [`ui/dr-ui/src/lib.rs:2901`](../../ui/dr-ui/src/lib.rs#L2901), [`ui/dr-ui/src/lib.rs:3108`](../../ui/dr-ui/src/lib.rs#L3108), [`ui/dr-ui/src/lib.rs:3239`](../../ui/dr-ui/src/lib.rs#L3239), [`ui/dr-ui/src/lib.rs:3444`](../../ui/dr-ui/src/lib.rs#L3444), [`ui/dr-ui/src/lib.rs:3508`](../../ui/dr-ui/src/lib.rs#L3508), [`ui/dr-ui/src/lib.rs:3562`](../../ui/dr-ui/src/lib.rs#L3562), [`ui/dr-ui/src/lib.rs:419`](../../ui/dr-ui/src/lib.rs#L419), [`ui/dr-ui/src/lib.rs:448`](../../ui/dr-ui/src/lib.rs#L448), [`ui/dr-ui/src/lib.rs:554`](../../ui/dr-ui/src/lib.rs#L554), [`ui/dr-ui/src/lib.rs:578`](../../ui/dr-ui/src/lib.rs#L578), [`ui/dr-ui/src/library/sidecar.rs:63`](../../ui/dr-ui/src/library/sidecar.rs#L63), [`ui/dr-ui/src/masks_ui.rs:1471`](../../ui/dr-ui/src/masks_ui.rs#L1471), [`ui/dr-ui/src/masks_ui.rs:1585`](../../ui/dr-ui/src/masks_ui.rs#L1585), [`ui/dr-ui/src/masks_ui.rs:339`](../../ui/dr-ui/src/masks_ui.rs#L339), [`ui/dr-ui/src/masks_ui.rs:44`](../../ui/dr-ui/src/masks_ui.rs#L44), [`ui/dr-ui/src/presets.rs:414`](../../ui/dr-ui/src/presets.rs#L414), [`ui/dr-ui/src/segmentation.rs:117`](../../ui/dr-ui/src/segmentation.rs#L117), [`ui/dr-ui/src/segmentation.rs:353`](../../ui/dr-ui/src/segmentation.rs#L353), [`ui/dr-ui/src/segmentation.rs:369`](../../ui/dr-ui/src/segmentation.rs#L369), [`ui/dr-ui/src/segmentation.rs:499`](../../ui/dr-ui/src/segmentation.rs#L499), [`ui/dr-ui/src/segmentation.rs:527`](../../ui/dr-ui/src/segmentation.rs#L527), [`ui/dr-ui/ui/adjust.slint:1019`](../../ui/dr-ui/ui/adjust.slint#L1019), [`ui/dr-ui/ui/adjust.slint:126`](../../ui/dr-ui/ui/adjust.slint#L126), [`ui/dr-ui/ui/adjust.slint:1496`](../../ui/dr-ui/ui/adjust.slint#L1496), [`ui/dr-ui/ui/adjust.slint:171`](../../ui/dr-ui/ui/adjust.slint#L171), [`ui/dr-ui/ui/adjust.slint:46`](../../ui/dr-ui/ui/adjust.slint#L46), [`ui/dr-ui/ui/adjust.slint:624`](../../ui/dr-ui/ui/adjust.slint#L624), [`ui/dr-ui/ui/adjust.slint:650`](../../ui/dr-ui/ui/adjust.slint#L650), [`ui/dr-ui/ui/adjust.slint:669`](../../ui/dr-ui/ui/adjust.slint#L669), [`ui/dr-ui/ui/adjust.slint:790`](../../ui/dr-ui/ui/adjust.slint#L790), [`ui/dr-ui/ui/adjust.slint:947`](../../ui/dr-ui/ui/adjust.slint#L947), [`ui/dr-ui/ui/app.slint:149`](../../ui/dr-ui/ui/app.slint#L149), [`ui/dr-ui/ui/app.slint:2325`](../../ui/dr-ui/ui/app.slint#L2325), [`ui/dr-ui/ui/app.slint:2385`](../../ui/dr-ui/ui/app.slint#L2385), [`ui/dr-ui/ui/develop.slint:223`](../../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/masks.slint:1216`](../../ui/dr-ui/ui/masks.slint#L1216), [`ui/dr-ui/ui/masks.slint:753`](../../ui/dr-ui/ui/masks.slint#L753), [`ui/dr-ui/ui/masks.slint:812`](../../ui/dr-ui/ui/masks.slint#L812) | +| FR-DEV-3a | [`core/dr-pipeline/ops/exposure.yaml:1`](../../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:212`](../../core/dr-pipeline/src/descriptor.rs#L212), [`core/dr-pipeline/src/descriptor.rs:252`](../../core/dr-pipeline/src/descriptor.rs#L252), [`core/dr-pipeline/src/descriptor.rs:276`](../../core/dr-pipeline/src/descriptor.rs#L276), [`core/dr-pipeline/src/descriptor.rs:331`](../../core/dr-pipeline/src/descriptor.rs#L331), [`core/dr-pipeline/src/framing.rs:395`](../../core/dr-pipeline/src/framing.rs#L395), [`core/dr-pipeline/src/graph.rs:25`](../../core/dr-pipeline/src/graph.rs#L25), [`core/dr-pipeline/src/graph.rs:393`](../../core/dr-pipeline/src/graph.rs#L393), [`core/dr-pipeline/src/graph.rs:47`](../../core/dr-pipeline/src/graph.rs#L47), [`core/dr-pipeline/src/graph.rs:534`](../../core/dr-pipeline/src/graph.rs#L534), [`core/dr-pipeline/src/graph.rs:60`](../../core/dr-pipeline/src/graph.rs#L60), [`core/dr-pipeline/src/mask.rs:1699`](../../core/dr-pipeline/src/mask.rs#L1699), [`core/dr-pipeline/src/neutral.rs:1`](../../core/dr-pipeline/src/neutral.rs#L1), [`core/dr-pipeline/src/neutral.rs:378`](../../core/dr-pipeline/src/neutral.rs#L378), [`core/dr-pipeline/src/operation.rs:232`](../../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop/curves.rs:36`](../../ui/dr-ui/src/develop/curves.rs#L36), [`ui/dr-ui/src/develop/rows.rs:118`](../../ui/dr-ui/src/develop/rows.rs#L118), [`ui/dr-ui/src/develop/rows.rs:679`](../../ui/dr-ui/src/develop/rows.rs#L679), [`ui/dr-ui/src/develop/rows.rs:877`](../../ui/dr-ui/src/develop/rows.rs#L877), [`ui/dr-ui/src/lib.rs:879`](../../ui/dr-ui/src/lib.rs#L879), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../../ui/dr-ui/tests/ui_names_no_operation.rs#L1), [`ui/dr-ui/ui/app.slint:2569`](../../ui/dr-ui/ui/app.slint#L2569) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:276`](../../core/dr-pipeline/src/descriptor.rs#L276), [`core/dr-pipeline/src/framing.rs:395`](../../core/dr-pipeline/src/framing.rs#L395), [`core/dr-pipeline/src/graph.rs:60`](../../core/dr-pipeline/src/graph.rs#L60), [`core/dr-pipeline/src/operation.rs:365`](../../core/dr-pipeline/src/operation.rs#L365), [`ui/dr-ui/ui/app.slint:2780`](../../ui/dr-ui/ui/app.slint#L2780) | -| FR-DEV-3c | [`core/dr-pipeline/ops/exposure.yaml:1`](../../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:393`](../../core/dr-pipeline/src/graph.rs#L393), [`core/dr-pipeline/src/graph.rs:47`](../../core/dr-pipeline/src/graph.rs#L47), [`core/dr-pipeline/src/mask.rs:1699`](../../core/dr-pipeline/src/mask.rs#L1699), [`core/dr-pipeline/src/preset.rs:751`](../../core/dr-pipeline/src/preset.rs#L751), [`ui/dr-ui/src/develop.rs:8158`](../../ui/dr-ui/src/develop.rs#L8158) | +| FR-DEV-3c | [`core/dr-pipeline/ops/exposure.yaml:1`](../../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:393`](../../core/dr-pipeline/src/graph.rs#L393), [`core/dr-pipeline/src/graph.rs:47`](../../core/dr-pipeline/src/graph.rs#L47), [`core/dr-pipeline/src/mask.rs:1699`](../../core/dr-pipeline/src/mask.rs#L1699), [`core/dr-pipeline/src/preset.rs:751`](../../core/dr-pipeline/src/preset.rs#L751), [`ui/dr-ui/src/develop/rows.rs:928`](../../ui/dr-ui/src/develop/rows.rs#L928) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1014`](../../core/dr-gpu/src/adjust.rs#L1014), [`core/dr-gpu/src/adjust.rs:1107`](../../core/dr-gpu/src/adjust.rs#L1107), [`core/dr-gpu/src/adjust.rs:114`](../../core/dr-gpu/src/adjust.rs#L114), [`core/dr-gpu/src/adjust.rs:798`](../../core/dr-gpu/src/adjust.rs#L798), [`core/dr-gpu/src/adjust.rs:84`](../../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:558`](../../core/dr-gpu/tests/local_contrast.rs#L558), [`core/dr-gpu/tests/noise_reduction.rs:556`](../../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:314`](../../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/graph.rs:991`](../../core/dr-pipeline/src/graph.rs#L991), [`core/dr-pipeline/src/operation.rs:32`](../../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:403`](../../core/dr-pipeline/src/operation.rs#L403), [`core/dr-pipeline/src/operation.rs:53`](../../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../../core/dr-pipeline/src/operation.rs#L71) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:771`](../../core/dr-decode/src/lib.rs#L771), [`core/dr-decode/src/lib.rs:811`](../../core/dr-decode/src/lib.rs#L811), [`core/dr-decode/src/profile.rs:102`](../../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:473`](../../core/dr-decode/src/profile.rs#L473), [`core/dr-decode/src/profile.rs:507`](../../core/dr-decode/src/profile.rs#L507), [`core/dr-decode/src/profile.rs:678`](../../core/dr-decode/src/profile.rs#L678), [`core/dr-gpu/src/adjust.rs:37`](../../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:995`](../../core/dr-gpu/src/adjust.rs#L995), [`core/dr-gpu/src/demosaic.rs:121`](../../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1985`](../../core/dr-pipeline/src/operation.rs#L1985), [`core/dr-pipeline/src/operation.rs:2066`](../../core/dr-pipeline/src/operation.rs#L2066), [`core/dr-pipeline/src/operation.rs:2091`](../../core/dr-pipeline/src/operation.rs#L2091), [`core/dr-pipeline/src/operation.rs:2106`](../../core/dr-pipeline/src/operation.rs#L2106), [`core/dr-pipeline/src/operation.rs:2130`](../../core/dr-pipeline/src/operation.rs#L2130), [`core/dr-pipeline/src/operation.rs:310`](../../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:471`](../../core/dr-pipeline/src/operation.rs#L471), [`core/dr-pipeline/src/operation.rs:481`](../../core/dr-pipeline/src/operation.rs#L481), [`core/dr-pipeline/src/operation.rs:769`](../../core/dr-pipeline/src/operation.rs#L769) | -| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:149`](../../core/dr-gpu/src/adjust.rs#L149), [`core/dr-gpu/src/adjust.rs:206`](../../core/dr-gpu/src/adjust.rs#L206), [`core/dr-gpu/src/adjust.rs:381`](../../core/dr-gpu/src/adjust.rs#L381), [`core/dr-gpu/src/adjust.rs:510`](../../core/dr-gpu/src/adjust.rs#L510), [`core/dr-gpu/src/adjust.rs:77`](../../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:195`](../../core/dr-gpu/tests/film_sim.rs#L195), [`core/dr-gpu/tests/film_sim.rs:1`](../../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:103`](../../core/dr-pipeline/src/graph.rs#L103), [`core/dr-pipeline/src/graph.rs:168`](../../core/dr-pipeline/src/graph.rs#L168), [`core/dr-pipeline/src/graph.rs:566`](../../core/dr-pipeline/src/graph.rs#L566), [`core/dr-pipeline/src/operation.rs:1313`](../../core/dr-pipeline/src/operation.rs#L1313), [`core/dr-pipeline/src/operation.rs:1341`](../../core/dr-pipeline/src/operation.rs#L1341), [`core/dr-pipeline/src/operation.rs:1387`](../../core/dr-pipeline/src/operation.rs#L1387), [`core/dr-pipeline/src/operation.rs:1985`](../../core/dr-pipeline/src/operation.rs#L1985), [`core/dr-pipeline/src/operation.rs:296`](../../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:102`](../../core/dr-pipeline/src/ops/film_sim.rs#L102), [`core/dr-pipeline/src/ops/film_sim.rs:139`](../../core/dr-pipeline/src/ops/film_sim.rs#L139), [`core/dr-pipeline/src/ops/film_sim.rs:163`](../../core/dr-pipeline/src/ops/film_sim.rs#L163), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:341`](../../core/dr-pipeline/src/ops/film_sim.rs#L341), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:97`](../../core/dr-pipeline/src/ops/film_sim.rs#L97), [`core/dr-pipeline/src/sidecar.rs:1055`](../../core/dr-pipeline/src/sidecar.rs#L1055), [`core/dr-pipeline/src/sidecar.rs:130`](../../core/dr-pipeline/src/sidecar.rs#L130), [`core/dr-pipeline/src/sidecar.rs:203`](../../core/dr-pipeline/src/sidecar.rs#L203), [`core/dr-pipeline/src/sidecar.rs:2681`](../../core/dr-pipeline/src/sidecar.rs#L2681), [`core/dr-pipeline/src/sidecar.rs:2757`](../../core/dr-pipeline/src/sidecar.rs#L2757), [`core/dr-pipeline/src/sidecar.rs:576`](../../core/dr-pipeline/src/sidecar.rs#L576), [`core/dr-pipeline/src/sidecar.rs:876`](../../core/dr-pipeline/src/sidecar.rs#L876), [`core/dr-pipeline/src/state.rs:100`](../../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:1170`](../../ui/dr-ui/src/develop.rs#L1170), [`ui/dr-ui/src/develop.rs:4810`](../../ui/dr-ui/src/develop.rs#L4810), [`ui/dr-ui/src/develop.rs:4827`](../../ui/dr-ui/src/develop.rs#L4827), [`ui/dr-ui/src/develop.rs:4839`](../../ui/dr-ui/src/develop.rs#L4839), [`ui/dr-ui/src/develop.rs:4877`](../../ui/dr-ui/src/develop.rs#L4877), [`ui/dr-ui/src/develop.rs:4886`](../../ui/dr-ui/src/develop.rs#L4886), [`ui/dr-ui/src/develop.rs:4989`](../../ui/dr-ui/src/develop.rs#L4989), [`ui/dr-ui/src/develop.rs:5500`](../../ui/dr-ui/src/develop.rs#L5500), [`ui/dr-ui/src/develop.rs:5671`](../../ui/dr-ui/src/develop.rs#L5671), [`ui/dr-ui/src/develop.rs:8649`](../../ui/dr-ui/src/develop.rs#L8649), [`ui/dr-ui/src/lib.rs:2873`](../../ui/dr-ui/src/lib.rs#L2873), [`ui/dr-ui/src/lib.rs:805`](../../ui/dr-ui/src/lib.rs#L805), [`ui/dr-ui/src/lib.rs:870`](../../ui/dr-ui/src/lib.rs#L870), [`ui/dr-ui/src/library.rs:602`](../../ui/dr-ui/src/library.rs#L602), [`ui/dr-ui/src/library.rs:870`](../../ui/dr-ui/src/library.rs#L870), [`ui/dr-ui/src/presets.rs:422`](../../ui/dr-ui/src/presets.rs#L422), [`ui/dr-ui/ui/adjust.slint:1040`](../../ui/dr-ui/ui/adjust.slint#L1040), [`ui/dr-ui/ui/adjust.slint:1050`](../../ui/dr-ui/ui/adjust.slint#L1050), [`ui/dr-ui/ui/adjust.slint:1268`](../../ui/dr-ui/ui/adjust.slint#L1268) | -| FR-DEV-3h | [`core/dr-decode/src/lib.rs:444`](../../core/dr-decode/src/lib.rs#L444), [`core/dr-decode/src/preview.rs:29`](../../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:1067`](../../core/dr-pipeline/src/framing.rs#L1067), [`core/dr-pipeline/src/framing.rs:328`](../../core/dr-pipeline/src/framing.rs#L328), [`core/dr-pipeline/src/framing.rs:498`](../../core/dr-pipeline/src/framing.rs#L498), [`core/dr-types/src/lib.rs:339`](../../core/dr-types/src/lib.rs#L339), [`core/dr-types/src/lib.rs:447`](../../core/dr-types/src/lib.rs#L447), [`core/dr-types/src/lib.rs:459`](../../core/dr-types/src/lib.rs#L459), [`core/dr-types/src/lib.rs:475`](../../core/dr-types/src/lib.rs#L475), [`ui/dr-ui/src/develop.rs:140`](../../ui/dr-ui/src/develop.rs#L140), [`ui/dr-ui/src/develop.rs:2778`](../../ui/dr-ui/src/develop.rs#L2778), [`ui/dr-ui/src/segmentation.rs:499`](../../ui/dr-ui/src/segmentation.rs#L499) | +| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:149`](../../core/dr-gpu/src/adjust.rs#L149), [`core/dr-gpu/src/adjust.rs:206`](../../core/dr-gpu/src/adjust.rs#L206), [`core/dr-gpu/src/adjust.rs:381`](../../core/dr-gpu/src/adjust.rs#L381), [`core/dr-gpu/src/adjust.rs:510`](../../core/dr-gpu/src/adjust.rs#L510), [`core/dr-gpu/src/adjust.rs:77`](../../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:195`](../../core/dr-gpu/tests/film_sim.rs#L195), [`core/dr-gpu/tests/film_sim.rs:1`](../../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:103`](../../core/dr-pipeline/src/graph.rs#L103), [`core/dr-pipeline/src/graph.rs:168`](../../core/dr-pipeline/src/graph.rs#L168), [`core/dr-pipeline/src/graph.rs:566`](../../core/dr-pipeline/src/graph.rs#L566), [`core/dr-pipeline/src/operation.rs:1313`](../../core/dr-pipeline/src/operation.rs#L1313), [`core/dr-pipeline/src/operation.rs:1341`](../../core/dr-pipeline/src/operation.rs#L1341), [`core/dr-pipeline/src/operation.rs:1387`](../../core/dr-pipeline/src/operation.rs#L1387), [`core/dr-pipeline/src/operation.rs:1985`](../../core/dr-pipeline/src/operation.rs#L1985), [`core/dr-pipeline/src/operation.rs:296`](../../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:102`](../../core/dr-pipeline/src/ops/film_sim.rs#L102), [`core/dr-pipeline/src/ops/film_sim.rs:139`](../../core/dr-pipeline/src/ops/film_sim.rs#L139), [`core/dr-pipeline/src/ops/film_sim.rs:163`](../../core/dr-pipeline/src/ops/film_sim.rs#L163), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:341`](../../core/dr-pipeline/src/ops/film_sim.rs#L341), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:97`](../../core/dr-pipeline/src/ops/film_sim.rs#L97), [`core/dr-pipeline/src/sidecar.rs:1055`](../../core/dr-pipeline/src/sidecar.rs#L1055), [`core/dr-pipeline/src/sidecar.rs:130`](../../core/dr-pipeline/src/sidecar.rs#L130), [`core/dr-pipeline/src/sidecar.rs:203`](../../core/dr-pipeline/src/sidecar.rs#L203), [`core/dr-pipeline/src/sidecar.rs:2681`](../../core/dr-pipeline/src/sidecar.rs#L2681), [`core/dr-pipeline/src/sidecar.rs:2757`](../../core/dr-pipeline/src/sidecar.rs#L2757), [`core/dr-pipeline/src/sidecar.rs:576`](../../core/dr-pipeline/src/sidecar.rs#L576), [`core/dr-pipeline/src/sidecar.rs:876`](../../core/dr-pipeline/src/sidecar.rs#L876), [`core/dr-pipeline/src/state.rs:100`](../../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop/render.rs:1051`](../../ui/dr-ui/src/develop/render.rs#L1051), [`ui/dr-ui/src/develop/render.rs:642`](../../ui/dr-ui/src/develop/render.rs#L642), [`ui/dr-ui/src/develop/render.rs:659`](../../ui/dr-ui/src/develop/render.rs#L659), [`ui/dr-ui/src/develop/render.rs:671`](../../ui/dr-ui/src/develop/render.rs#L671), [`ui/dr-ui/src/develop/render.rs:709`](../../ui/dr-ui/src/develop/render.rs#L709), [`ui/dr-ui/src/develop/render.rs:718`](../../ui/dr-ui/src/develop/render.rs#L718), [`ui/dr-ui/src/develop/render.rs:821`](../../ui/dr-ui/src/develop/render.rs#L821), [`ui/dr-ui/src/develop/render.rs:847`](../../ui/dr-ui/src/develop/render.rs#L847), [`ui/dr-ui/src/develop/session.rs:598`](../../ui/dr-ui/src/develop/session.rs#L598), [`ui/dr-ui/src/develop/tabs.rs:80`](../../ui/dr-ui/src/develop/tabs.rs#L80), [`ui/dr-ui/src/lib.rs:2873`](../../ui/dr-ui/src/lib.rs#L2873), [`ui/dr-ui/src/lib.rs:805`](../../ui/dr-ui/src/lib.rs#L805), [`ui/dr-ui/src/lib.rs:870`](../../ui/dr-ui/src/lib.rs#L870), [`ui/dr-ui/src/library/sidecar.rs:317`](../../ui/dr-ui/src/library/sidecar.rs#L317), [`ui/dr-ui/src/library/sidecar.rs:49`](../../ui/dr-ui/src/library/sidecar.rs#L49), [`ui/dr-ui/src/presets.rs:422`](../../ui/dr-ui/src/presets.rs#L422), [`ui/dr-ui/ui/adjust.slint:1040`](../../ui/dr-ui/ui/adjust.slint#L1040), [`ui/dr-ui/ui/adjust.slint:1050`](../../ui/dr-ui/ui/adjust.slint#L1050), [`ui/dr-ui/ui/adjust.slint:1268`](../../ui/dr-ui/ui/adjust.slint#L1268) | +| FR-DEV-3h | [`core/dr-decode/src/lib.rs:444`](../../core/dr-decode/src/lib.rs#L444), [`core/dr-decode/src/preview.rs:29`](../../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:1067`](../../core/dr-pipeline/src/framing.rs#L1067), [`core/dr-pipeline/src/framing.rs:328`](../../core/dr-pipeline/src/framing.rs#L328), [`core/dr-pipeline/src/framing.rs:498`](../../core/dr-pipeline/src/framing.rs#L498), [`core/dr-types/src/lib.rs:339`](../../core/dr-types/src/lib.rs#L339), [`core/dr-types/src/lib.rs:447`](../../core/dr-types/src/lib.rs#L447), [`core/dr-types/src/lib.rs:459`](../../core/dr-types/src/lib.rs#L459), [`core/dr-types/src/lib.rs:475`](../../core/dr-types/src/lib.rs#L475), [`ui/dr-ui/src/develop/masks.rs:279`](../../ui/dr-ui/src/develop/masks.rs#L279), [`ui/dr-ui/src/develop/segmentation.rs:114`](../../ui/dr-ui/src/develop/segmentation.rs#L114), [`ui/dr-ui/src/segmentation.rs:499`](../../ui/dr-ui/src/segmentation.rs#L499) | | FR-DEV-3i | [`core/dr-pipeline/src/mask.rs:558`](../../core/dr-pipeline/src/mask.rs#L558), [`core/dr-segment/src/lib.rs:1`](../../core/dr-segment/src/lib.rs#L1), [`ui/dr-ui/src/segmentation.rs:1`](../../ui/dr-ui/src/segmentation.rs#L1) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:798`](../../core/dr-gpu/src/adjust.rs#L798), [`core/dr-gpu/src/lib.rs:368`](../../core/dr-gpu/src/lib.rs#L368), [`ui/dr-ui/ui/crop.slint:1`](../../ui/dr-ui/ui/crop.slint#L1) | -| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:1440`](../../core/dr-pipeline/src/graph.rs#L1440), [`core/dr-pipeline/src/graph.rs:587`](../../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/graph.rs:640`](../../core/dr-pipeline/src/graph.rs#L640), [`core/dr-pipeline/src/history.rs:102`](../../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/sidecar.rs:1045`](../../core/dr-pipeline/src/sidecar.rs#L1045), [`core/dr-pipeline/src/sidecar.rs:144`](../../core/dr-pipeline/src/sidecar.rs#L144), [`core/dr-pipeline/src/sidecar.rs:2472`](../../core/dr-pipeline/src/sidecar.rs#L2472), [`core/dr-pipeline/src/sidecar.rs:2508`](../../core/dr-pipeline/src/sidecar.rs#L2508), [`core/dr-pipeline/src/sidecar.rs:2520`](../../core/dr-pipeline/src/sidecar.rs#L2520), [`core/dr-pipeline/src/sidecar.rs:303`](../../core/dr-pipeline/src/sidecar.rs#L303), [`core/dr-pipeline/src/sidecar.rs:694`](../../core/dr-pipeline/src/sidecar.rs#L694), [`core/dr-pipeline/src/sidecar.rs:710`](../../core/dr-pipeline/src/sidecar.rs#L710), [`core/dr-pipeline/src/sidecar.rs:857`](../../core/dr-pipeline/src/sidecar.rs#L857), [`core/dr-pipeline/src/state.rs:1`](../../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:4409`](../../ui/dr-ui/src/develop.rs#L4409), [`ui/dr-ui/src/develop.rs:4839`](../../ui/dr-ui/src/develop.rs#L4839), [`ui/dr-ui/src/develop.rs:5517`](../../ui/dr-ui/src/develop.rs#L5517), [`ui/dr-ui/src/develop.rs:5535`](../../ui/dr-ui/src/develop.rs#L5535), [`ui/dr-ui/src/develop.rs:5574`](../../ui/dr-ui/src/develop.rs#L5574), [`ui/dr-ui/src/develop.rs:5588`](../../ui/dr-ui/src/develop.rs#L5588), [`ui/dr-ui/src/develop.rs:5599`](../../ui/dr-ui/src/develop.rs#L5599), [`ui/dr-ui/src/develop.rs:5658`](../../ui/dr-ui/src/develop.rs#L5658), [`ui/dr-ui/src/develop.rs:5671`](../../ui/dr-ui/src/develop.rs#L5671), [`ui/dr-ui/src/develop.rs:5701`](../../ui/dr-ui/src/develop.rs#L5701), [`ui/dr-ui/src/develop.rs:5714`](../../ui/dr-ui/src/develop.rs#L5714), [`ui/dr-ui/src/develop.rs:5726`](../../ui/dr-ui/src/develop.rs#L5726), [`ui/dr-ui/src/develop.rs:5742`](../../ui/dr-ui/src/develop.rs#L5742), [`ui/dr-ui/src/develop.rs:5774`](../../ui/dr-ui/src/develop.rs#L5774), [`ui/dr-ui/src/develop.rs:5778`](../../ui/dr-ui/src/develop.rs#L5778), [`ui/dr-ui/src/develop.rs:5797`](../../ui/dr-ui/src/develop.rs#L5797), [`ui/dr-ui/src/develop.rs:5813`](../../ui/dr-ui/src/develop.rs#L5813), [`ui/dr-ui/src/develop.rs:6655`](../../ui/dr-ui/src/develop.rs#L6655), [`ui/dr-ui/src/develop.rs:6675`](../../ui/dr-ui/src/develop.rs#L6675), [`ui/dr-ui/src/develop.rs:6897`](../../ui/dr-ui/src/develop.rs#L6897), [`ui/dr-ui/src/develop.rs:6954`](../../ui/dr-ui/src/develop.rs#L6954), [`ui/dr-ui/src/develop.rs:7014`](../../ui/dr-ui/src/develop.rs#L7014), [`ui/dr-ui/src/develop.rs:772`](../../ui/dr-ui/src/develop.rs#L772), [`ui/dr-ui/src/develop.rs:781`](../../ui/dr-ui/src/develop.rs#L781), [`ui/dr-ui/src/labels.rs:12`](../../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:278`](../../ui/dr-ui/src/labels.rs#L278), [`ui/dr-ui/src/labels.rs:31`](../../ui/dr-ui/src/labels.rs#L31), [`ui/dr-ui/src/lib.rs:1921`](../../ui/dr-ui/src/lib.rs#L1921), [`ui/dr-ui/src/lib.rs:1926`](../../ui/dr-ui/src/lib.rs#L1926), [`ui/dr-ui/src/lib.rs:1989`](../../ui/dr-ui/src/lib.rs#L1989), [`ui/dr-ui/src/lib.rs:1998`](../../ui/dr-ui/src/lib.rs#L1998), [`ui/dr-ui/src/lib.rs:2010`](../../ui/dr-ui/src/lib.rs#L2010), [`ui/dr-ui/src/lib.rs:3139`](../../ui/dr-ui/src/lib.rs#L3139), [`ui/dr-ui/src/lib.rs:3159`](../../ui/dr-ui/src/lib.rs#L3159), [`ui/dr-ui/src/library.rs:611`](../../ui/dr-ui/src/library.rs#L611), [`ui/dr-ui/src/library.rs:896`](../../ui/dr-ui/src/library.rs#L896), [`ui/dr-ui/src/presets.rs:1678`](../../ui/dr-ui/src/presets.rs#L1678), [`ui/dr-ui/src/presets.rs:373`](../../ui/dr-ui/src/presets.rs#L373), [`ui/dr-ui/src/presets.rs:437`](../../ui/dr-ui/src/presets.rs#L437), [`ui/dr-ui/src/presets.rs:557`](../../ui/dr-ui/src/presets.rs#L557), [`ui/dr-ui/ui/history.slint:191`](../../ui/dr-ui/ui/history.slint#L191), [`ui/dr-ui/ui/history.slint:1`](../../ui/dr-ui/ui/history.slint#L1), [`ui/dr-ui/ui/history.slint:218`](../../ui/dr-ui/ui/history.slint#L218), [`ui/dr-ui/ui/history.slint:303`](../../ui/dr-ui/ui/history.slint#L303), [`ui/dr-ui/ui/history.slint:43`](../../ui/dr-ui/ui/history.slint#L43), [`ui/dr-ui/ui/history.slint:53`](../../ui/dr-ui/ui/history.slint#L53) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:390`](../../core/dr-pipeline/src/preset.rs#L390), [`core/dr-pipeline/src/preset.rs:423`](../../core/dr-pipeline/src/preset.rs#L423), [`core/dr-pipeline/src/starter.rs:1`](../../core/dr-pipeline/src/starter.rs#L1), [`core/dr-preset-xmp/src/lib.rs:1`](../../core/dr-preset-xmp/src/lib.rs#L1), [`core/dr-types/src/settings.rs:443`](../../core/dr-types/src/settings.rs#L443), [`ui/dr-ui/src/develop.rs:5457`](../../ui/dr-ui/src/develop.rs#L5457), [`ui/dr-ui/src/develop.rs:5478`](../../ui/dr-ui/src/develop.rs#L5478), [`ui/dr-ui/src/lib.rs:1888`](../../ui/dr-ui/src/lib.rs#L1888), [`ui/dr-ui/src/lib.rs:2674`](../../ui/dr-ui/src/lib.rs#L2674), [`ui/dr-ui/src/lib.rs:2703`](../../ui/dr-ui/src/lib.rs#L2703), [`ui/dr-ui/src/library.rs:2491`](../../ui/dr-ui/src/library.rs#L2491), [`ui/dr-ui/src/library.rs:564`](../../ui/dr-ui/src/library.rs#L564), [`ui/dr-ui/src/library.rs:592`](../../ui/dr-ui/src/library.rs#L592), [`ui/dr-ui/src/library_ui.rs:3255`](../../ui/dr-ui/src/library_ui.rs#L3255), [`ui/dr-ui/src/library_ui.rs:3838`](../../ui/dr-ui/src/library_ui.rs#L3838), [`ui/dr-ui/src/library_ui.rs:630`](../../ui/dr-ui/src/library_ui.rs#L630), [`ui/dr-ui/src/preset_store.rs:1`](../../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:707`](../../ui/dr-ui/src/presets.rs#L707), [`ui/dr-ui/src/presets.rs:741`](../../ui/dr-ui/src/presets.rs#L741), [`ui/dr-ui/ui/adjust.slint:853`](../../ui/dr-ui/ui/adjust.slint#L853), [`ui/dr-ui/ui/adjust.slint:878`](../../ui/dr-ui/ui/adjust.slint#L878), [`ui/dr-ui/ui/adjust.slint:889`](../../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:932`](../../ui/dr-ui/ui/adjust.slint#L932), [`ui/dr-ui/ui/app.slint:1706`](../../ui/dr-ui/ui/app.slint#L1706), [`ui/dr-ui/ui/app.slint:2538`](../../ui/dr-ui/ui/app.slint#L2538), [`ui/dr-ui/ui/app.slint:3173`](../../ui/dr-ui/ui/app.slint#L3173), [`ui/dr-ui/ui/app.slint:765`](../../ui/dr-ui/ui/app.slint#L765), [`ui/dr-ui/ui/app.slint:776`](../../ui/dr-ui/ui/app.slint#L776), [`ui/dr-ui/ui/library.slint:1579`](../../ui/dr-ui/ui/library.slint#L1579), [`ui/dr-ui/ui/library.slint:1585`](../../ui/dr-ui/ui/library.slint#L1585), [`ui/dr-ui/ui/library.slint:4262`](../../ui/dr-ui/ui/library.slint#L4262), [`ui/dr-ui/ui/library.slint:4286`](../../ui/dr-ui/ui/library.slint#L4286), [`ui/dr-ui/ui/presets.slint:105`](../../ui/dr-ui/ui/presets.slint#L105), [`ui/dr-ui/ui/presets.slint:113`](../../ui/dr-ui/ui/presets.slint#L113), [`ui/dr-ui/ui/presets.slint:19`](../../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/presets.slint:276`](../../ui/dr-ui/ui/presets.slint#L276), [`ui/dr-ui/ui/presets.slint:297`](../../ui/dr-ui/ui/presets.slint#L297), [`ui/dr-ui/ui/presets.slint:5`](../../ui/dr-ui/ui/presets.slint#L5), [`ui/dr-ui/ui/presets.slint:60`](../../ui/dr-ui/ui/presets.slint#L60), [`ui/dr-ui/ui/settings.slint:119`](../../ui/dr-ui/ui/settings.slint#L119), [`ui/dr-ui/ui/settings.slint:711`](../../ui/dr-ui/ui/settings.slint#L711) | -| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:4343`](../../ui/dr-ui/src/develop.rs#L4343), [`ui/dr-ui/src/develop.rs:4384`](../../ui/dr-ui/src/develop.rs#L4384), [`ui/dr-ui/src/develop.rs:5613`](../../ui/dr-ui/src/develop.rs#L5613), [`ui/dr-ui/src/develop.rs:5630`](../../ui/dr-ui/src/develop.rs#L5630), [`ui/dr-ui/src/develop.rs:5742`](../../ui/dr-ui/src/develop.rs#L5742), [`ui/dr-ui/src/develop.rs:5774`](../../ui/dr-ui/src/develop.rs#L5774), [`ui/dr-ui/src/develop.rs:6897`](../../ui/dr-ui/src/develop.rs#L6897), [`ui/dr-ui/src/develop.rs:7014`](../../ui/dr-ui/src/develop.rs#L7014), [`ui/dr-ui/src/develop.rs:797`](../../ui/dr-ui/src/develop.rs#L797), [`ui/dr-ui/src/lib.rs:1998`](../../ui/dr-ui/src/lib.rs#L1998), [`ui/dr-ui/src/lib.rs:2070`](../../ui/dr-ui/src/lib.rs#L2070), [`ui/dr-ui/src/lib.rs:2084`](../../ui/dr-ui/src/lib.rs#L2084), [`ui/dr-ui/src/lib.rs:3080`](../../ui/dr-ui/src/lib.rs#L3080), [`ui/dr-ui/src/lib.rs:3139`](../../ui/dr-ui/src/lib.rs#L3139), [`ui/dr-ui/src/lib.rs:3211`](../../ui/dr-ui/src/lib.rs#L3211), [`ui/dr-ui/src/lib.rs:411`](../../ui/dr-ui/src/lib.rs#L411), [`ui/dr-ui/ui/app.slint:131`](../../ui/dr-ui/ui/app.slint#L131), [`ui/dr-ui/ui/app.slint:2445`](../../ui/dr-ui/ui/app.slint#L2445), [`ui/dr-ui/ui/app.slint:2460`](../../ui/dr-ui/ui/app.slint#L2460), [`ui/dr-ui/ui/app.slint:2780`](../../ui/dr-ui/ui/app.slint#L2780), [`ui/dr-ui/ui/history.slint:102`](../../ui/dr-ui/ui/history.slint#L102), [`ui/dr-ui/ui/history.slint:1`](../../ui/dr-ui/ui/history.slint#L1), [`ui/dr-ui/ui/history.slint:226`](../../ui/dr-ui/ui/history.slint#L226), [`ui/dr-ui/ui/history.slint:53`](../../ui/dr-ui/ui/history.slint#L53), [`ui/dr-ui/ui/widgets.slint:71`](../../ui/dr-ui/ui/widgets.slint#L71) | -| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:405`](../../core/dr-gpu/src/detail.rs#L405), [`core/dr-gpu/src/detail.rs:623`](../../core/dr-gpu/src/detail.rs#L623), [`core/dr-gpu/tests/detail_instances.rs:1`](../../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:1092`](../../core/dr-pipeline/src/graph.rs#L1092), [`core/dr-pipeline/src/graph.rs:115`](../../core/dr-pipeline/src/graph.rs#L115), [`core/dr-pipeline/src/graph.rs:247`](../../core/dr-pipeline/src/graph.rs#L247), [`core/dr-pipeline/src/operation.rs:330`](../../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:717`](../../core/dr-pipeline/src/operation.rs#L717), [`core/dr-pipeline/src/sidecar.rs:1071`](../../core/dr-pipeline/src/sidecar.rs#L1071), [`core/dr-pipeline/src/sidecar.rs:1125`](../../core/dr-pipeline/src/sidecar.rs#L1125), [`core/dr-pipeline/src/sidecar.rs:1155`](../../core/dr-pipeline/src/sidecar.rs#L1155), [`core/dr-pipeline/src/sidecar.rs:219`](../../core/dr-pipeline/src/sidecar.rs#L219), [`core/dr-pipeline/src/sidecar.rs:395`](../../core/dr-pipeline/src/sidecar.rs#L395), [`core/dr-pipeline/src/sidecar.rs:888`](../../core/dr-pipeline/src/sidecar.rs#L888), [`core/dr-pipeline/src/spot.rs:115`](../../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:3209`](../../ui/dr-ui/src/develop.rs#L3209), [`ui/dr-ui/src/develop.rs:3247`](../../ui/dr-ui/src/develop.rs#L3247), [`ui/dr-ui/src/develop.rs:3312`](../../ui/dr-ui/src/develop.rs#L3312), [`ui/dr-ui/src/develop.rs:3395`](../../ui/dr-ui/src/develop.rs#L3395), [`ui/dr-ui/src/develop.rs:3409`](../../ui/dr-ui/src/develop.rs#L3409), [`ui/dr-ui/src/develop.rs:904`](../../ui/dr-ui/src/develop.rs#L904), [`ui/dr-ui/src/labels.rs:80`](../../ui/dr-ui/src/labels.rs#L80), [`ui/dr-ui/src/lib.rs:2028`](../../ui/dr-ui/src/lib.rs#L2028), [`ui/dr-ui/src/lib.rs:3364`](../../ui/dr-ui/src/lib.rs#L3364), [`ui/dr-ui/src/lib.rs:453`](../../ui/dr-ui/src/lib.rs#L453), [`ui/dr-ui/src/spots_ui.rs:19`](../../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/app.slint:2301`](../../ui/dr-ui/ui/app.slint#L2301), [`ui/dr-ui/ui/app.slint:2503`](../../ui/dr-ui/ui/app.slint#L2503), [`ui/dr-ui/ui/app.slint:3079`](../../ui/dr-ui/ui/app.slint#L3079), [`ui/dr-ui/ui/icons.slint:366`](../../ui/dr-ui/ui/icons.slint#L366), [`ui/dr-ui/ui/session.slint:102`](../../ui/dr-ui/ui/session.slint#L102), [`ui/dr-ui/ui/session.slint:52`](../../ui/dr-ui/ui/session.slint#L52), [`ui/dr-ui/ui/spots.slint:191`](../../ui/dr-ui/ui/spots.slint#L191), [`ui/dr-ui/ui/spots.slint:49`](../../ui/dr-ui/ui/spots.slint#L49), [`ui/dr-ui/ui/spots.slint:6`](../../ui/dr-ui/ui/spots.slint#L6), [`ui/dr-ui/ui/spots.slint:91`](../../ui/dr-ui/ui/spots.slint#L91) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2366`](../../core/dr-gpu/src/adjust.rs#L2366), [`core/dr-gpu/src/adjust.rs:2443`](../../core/dr-gpu/src/adjust.rs#L2443), [`core/dr-gpu/src/adjust.rs:2528`](../../core/dr-gpu/src/adjust.rs#L2528), [`core/dr-gpu/src/adjust.rs:54`](../../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:798`](../../core/dr-gpu/src/adjust.rs#L798), [`core/dr-gpu/src/lib.rs:174`](../../core/dr-gpu/src/lib.rs#L174), [`core/dr-gpu/src/lib.rs:194`](../../core/dr-gpu/src/lib.rs#L194), [`core/dr-gpu/src/lib.rs:72`](../../core/dr-gpu/src/lib.rs#L72), [`core/dr-gpu/src/lib.rs:95`](../../core/dr-gpu/src/lib.rs#L95), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:922`](../../core/dr-pipeline/src/graph.rs#L922), [`core/dr-pipeline/src/graph.rs:952`](../../core/dr-pipeline/src/graph.rs#L952), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:659`](../../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/dehaze.rs:1`](../../core/dr-pipeline/src/ops/dehaze.rs#L1), [`core/dr-pipeline/src/ops/dehaze.rs:592`](../../core/dr-pipeline/src/ops/dehaze.rs#L592), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:4030`](../../ui/dr-ui/src/develop.rs#L4030), [`ui/dr-ui/src/develop.rs:5325`](../../ui/dr-ui/src/develop.rs#L5325), [`ui/dr-ui/src/develop.rs:6068`](../../ui/dr-ui/src/develop.rs#L6068), [`ui/dr-ui/src/develop.rs:7608`](../../ui/dr-ui/src/develop.rs#L7608), [`ui/dr-ui/src/develop.rs:7642`](../../ui/dr-ui/src/develop.rs#L7642), [`ui/dr-ui/src/lib.rs:100`](../../ui/dr-ui/src/lib.rs#L100), [`ui/dr-ui/src/lib.rs:1026`](../../ui/dr-ui/src/lib.rs#L1026), [`ui/dr-ui/src/lib.rs:1085`](../../ui/dr-ui/src/lib.rs#L1085) | +| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:1440`](../../core/dr-pipeline/src/graph.rs#L1440), [`core/dr-pipeline/src/graph.rs:587`](../../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/graph.rs:640`](../../core/dr-pipeline/src/graph.rs#L640), [`core/dr-pipeline/src/history.rs:102`](../../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/sidecar.rs:1045`](../../core/dr-pipeline/src/sidecar.rs#L1045), [`core/dr-pipeline/src/sidecar.rs:144`](../../core/dr-pipeline/src/sidecar.rs#L144), [`core/dr-pipeline/src/sidecar.rs:2472`](../../core/dr-pipeline/src/sidecar.rs#L2472), [`core/dr-pipeline/src/sidecar.rs:2508`](../../core/dr-pipeline/src/sidecar.rs#L2508), [`core/dr-pipeline/src/sidecar.rs:2520`](../../core/dr-pipeline/src/sidecar.rs#L2520), [`core/dr-pipeline/src/sidecar.rs:303`](../../core/dr-pipeline/src/sidecar.rs#L303), [`core/dr-pipeline/src/sidecar.rs:694`](../../core/dr-pipeline/src/sidecar.rs#L694), [`core/dr-pipeline/src/sidecar.rs:710`](../../core/dr-pipeline/src/sidecar.rs#L710), [`core/dr-pipeline/src/sidecar.rs:857`](../../core/dr-pipeline/src/sidecar.rs#L857), [`core/dr-pipeline/src/state.rs:1`](../../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop/framing.rs:720`](../../ui/dr-ui/src/develop/framing.rs#L720), [`ui/dr-ui/src/develop/history.rs:11`](../../ui/dr-ui/src/develop/history.rs#L11), [`ui/dr-ui/src/develop/history.rs:152`](../../ui/dr-ui/src/develop/history.rs#L152), [`ui/dr-ui/src/develop/history.rs:165`](../../ui/dr-ui/src/develop/history.rs#L165), [`ui/dr-ui/src/develop/history.rs:178`](../../ui/dr-ui/src/develop/history.rs#L178), [`ui/dr-ui/src/develop/history.rs:190`](../../ui/dr-ui/src/develop/history.rs#L190), [`ui/dr-ui/src/develop/history.rs:206`](../../ui/dr-ui/src/develop/history.rs#L206), [`ui/dr-ui/src/develop/history.rs:238`](../../ui/dr-ui/src/develop/history.rs#L238), [`ui/dr-ui/src/develop/history.rs:242`](../../ui/dr-ui/src/develop/history.rs#L242), [`ui/dr-ui/src/develop/history.rs:261`](../../ui/dr-ui/src/develop/history.rs#L261), [`ui/dr-ui/src/develop/history.rs:277`](../../ui/dr-ui/src/develop/history.rs#L277), [`ui/dr-ui/src/develop/history.rs:299`](../../ui/dr-ui/src/develop/history.rs#L299), [`ui/dr-ui/src/develop/history.rs:29`](../../ui/dr-ui/src/develop/history.rs#L29), [`ui/dr-ui/src/develop/history.rs:359`](../../ui/dr-ui/src/develop/history.rs#L359), [`ui/dr-ui/src/develop/history.rs:68`](../../ui/dr-ui/src/develop/history.rs#L68), [`ui/dr-ui/src/develop/history.rs:82`](../../ui/dr-ui/src/develop/history.rs#L82), [`ui/dr-ui/src/develop/history.rs:93`](../../ui/dr-ui/src/develop/history.rs#L93), [`ui/dr-ui/src/develop/render.rs:671`](../../ui/dr-ui/src/develop/render.rs#L671), [`ui/dr-ui/src/develop/render.rs:847`](../../ui/dr-ui/src/develop/render.rs#L847), [`ui/dr-ui/src/develop/render.rs:883`](../../ui/dr-ui/src/develop/render.rs#L883), [`ui/dr-ui/src/develop/session.rs:80`](../../ui/dr-ui/src/develop/session.rs#L80), [`ui/dr-ui/src/develop/session.rs:89`](../../ui/dr-ui/src/develop/session.rs#L89), [`ui/dr-ui/src/develop/white_balance.rs:11`](../../ui/dr-ui/src/develop/white_balance.rs#L11), [`ui/dr-ui/src/develop/white_balance.rs:162`](../../ui/dr-ui/src/develop/white_balance.rs#L162), [`ui/dr-ui/src/labels.rs:12`](../../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:278`](../../ui/dr-ui/src/labels.rs#L278), [`ui/dr-ui/src/labels.rs:31`](../../ui/dr-ui/src/labels.rs#L31), [`ui/dr-ui/src/lib.rs:1921`](../../ui/dr-ui/src/lib.rs#L1921), [`ui/dr-ui/src/lib.rs:1926`](../../ui/dr-ui/src/lib.rs#L1926), [`ui/dr-ui/src/lib.rs:1989`](../../ui/dr-ui/src/lib.rs#L1989), [`ui/dr-ui/src/lib.rs:1998`](../../ui/dr-ui/src/lib.rs#L1998), [`ui/dr-ui/src/lib.rs:2010`](../../ui/dr-ui/src/lib.rs#L2010), [`ui/dr-ui/src/lib.rs:3139`](../../ui/dr-ui/src/lib.rs#L3139), [`ui/dr-ui/src/lib.rs:3159`](../../ui/dr-ui/src/lib.rs#L3159), [`ui/dr-ui/src/library/sidecar.rs:343`](../../ui/dr-ui/src/library/sidecar.rs#L343), [`ui/dr-ui/src/library/sidecar.rs:58`](../../ui/dr-ui/src/library/sidecar.rs#L58), [`ui/dr-ui/src/presets.rs:1678`](../../ui/dr-ui/src/presets.rs#L1678), [`ui/dr-ui/src/presets.rs:373`](../../ui/dr-ui/src/presets.rs#L373), [`ui/dr-ui/src/presets.rs:437`](../../ui/dr-ui/src/presets.rs#L437), [`ui/dr-ui/src/presets.rs:557`](../../ui/dr-ui/src/presets.rs#L557), [`ui/dr-ui/ui/history.slint:191`](../../ui/dr-ui/ui/history.slint#L191), [`ui/dr-ui/ui/history.slint:1`](../../ui/dr-ui/ui/history.slint#L1), [`ui/dr-ui/ui/history.slint:218`](../../ui/dr-ui/ui/history.slint#L218), [`ui/dr-ui/ui/history.slint:303`](../../ui/dr-ui/ui/history.slint#L303), [`ui/dr-ui/ui/history.slint:43`](../../ui/dr-ui/ui/history.slint#L43), [`ui/dr-ui/ui/history.slint:53`](../../ui/dr-ui/ui/history.slint#L53) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:390`](../../core/dr-pipeline/src/preset.rs#L390), [`core/dr-pipeline/src/preset.rs:423`](../../core/dr-pipeline/src/preset.rs#L423), [`core/dr-pipeline/src/starter.rs:1`](../../core/dr-pipeline/src/starter.rs#L1), [`core/dr-preset-xmp/src/lib.rs:1`](../../core/dr-preset-xmp/src/lib.rs#L1), [`core/dr-types/src/settings.rs:443`](../../core/dr-types/src/settings.rs#L443), [`ui/dr-ui/src/develop/session.rs:555`](../../ui/dr-ui/src/develop/session.rs#L555), [`ui/dr-ui/src/develop/session.rs:576`](../../ui/dr-ui/src/develop/session.rs#L576), [`ui/dr-ui/src/lib.rs:1888`](../../ui/dr-ui/src/lib.rs#L1888), [`ui/dr-ui/src/lib.rs:2674`](../../ui/dr-ui/src/lib.rs#L2674), [`ui/dr-ui/src/lib.rs:2703`](../../ui/dr-ui/src/lib.rs#L2703), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11), [`ui/dr-ui/src/library/sidecar.rs:39`](../../ui/dr-ui/src/library/sidecar.rs#L39), [`ui/dr-ui/src/library/thumbnails_fetch.rs:413`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L413), [`ui/dr-ui/src/library_ui.rs:3255`](../../ui/dr-ui/src/library_ui.rs#L3255), [`ui/dr-ui/src/library_ui.rs:3838`](../../ui/dr-ui/src/library_ui.rs#L3838), [`ui/dr-ui/src/library_ui.rs:630`](../../ui/dr-ui/src/library_ui.rs#L630), [`ui/dr-ui/src/preset_store.rs:1`](../../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:707`](../../ui/dr-ui/src/presets.rs#L707), [`ui/dr-ui/src/presets.rs:741`](../../ui/dr-ui/src/presets.rs#L741), [`ui/dr-ui/ui/adjust.slint:853`](../../ui/dr-ui/ui/adjust.slint#L853), [`ui/dr-ui/ui/adjust.slint:878`](../../ui/dr-ui/ui/adjust.slint#L878), [`ui/dr-ui/ui/adjust.slint:889`](../../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:932`](../../ui/dr-ui/ui/adjust.slint#L932), [`ui/dr-ui/ui/app.slint:1706`](../../ui/dr-ui/ui/app.slint#L1706), [`ui/dr-ui/ui/app.slint:2538`](../../ui/dr-ui/ui/app.slint#L2538), [`ui/dr-ui/ui/app.slint:3173`](../../ui/dr-ui/ui/app.slint#L3173), [`ui/dr-ui/ui/app.slint:765`](../../ui/dr-ui/ui/app.slint#L765), [`ui/dr-ui/ui/app.slint:776`](../../ui/dr-ui/ui/app.slint#L776), [`ui/dr-ui/ui/library.slint:1579`](../../ui/dr-ui/ui/library.slint#L1579), [`ui/dr-ui/ui/library.slint:1585`](../../ui/dr-ui/ui/library.slint#L1585), [`ui/dr-ui/ui/library.slint:4262`](../../ui/dr-ui/ui/library.slint#L4262), [`ui/dr-ui/ui/library.slint:4286`](../../ui/dr-ui/ui/library.slint#L4286), [`ui/dr-ui/ui/presets.slint:105`](../../ui/dr-ui/ui/presets.slint#L105), [`ui/dr-ui/ui/presets.slint:113`](../../ui/dr-ui/ui/presets.slint#L113), [`ui/dr-ui/ui/presets.slint:19`](../../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/presets.slint:276`](../../ui/dr-ui/ui/presets.slint#L276), [`ui/dr-ui/ui/presets.slint:297`](../../ui/dr-ui/ui/presets.slint#L297), [`ui/dr-ui/ui/presets.slint:5`](../../ui/dr-ui/ui/presets.slint#L5), [`ui/dr-ui/ui/presets.slint:60`](../../ui/dr-ui/ui/presets.slint#L60), [`ui/dr-ui/ui/settings.slint:119`](../../ui/dr-ui/ui/settings.slint#L119), [`ui/dr-ui/ui/settings.slint:711`](../../ui/dr-ui/ui/settings.slint#L711) | +| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop/history.rs:107`](../../ui/dr-ui/src/develop/history.rs#L107), [`ui/dr-ui/src/develop/history.rs:124`](../../ui/dr-ui/src/develop/history.rs#L124), [`ui/dr-ui/src/develop/history.rs:206`](../../ui/dr-ui/src/develop/history.rs#L206), [`ui/dr-ui/src/develop/history.rs:238`](../../ui/dr-ui/src/develop/history.rs#L238), [`ui/dr-ui/src/develop/history.rs:359`](../../ui/dr-ui/src/develop/history.rs#L359), [`ui/dr-ui/src/develop/render.rs:453`](../../ui/dr-ui/src/develop/render.rs#L453), [`ui/dr-ui/src/develop/render.rs:494`](../../ui/dr-ui/src/develop/render.rs#L494), [`ui/dr-ui/src/develop/render.rs:883`](../../ui/dr-ui/src/develop/render.rs#L883), [`ui/dr-ui/src/develop/session.rs:105`](../../ui/dr-ui/src/develop/session.rs#L105), [`ui/dr-ui/src/lib.rs:1998`](../../ui/dr-ui/src/lib.rs#L1998), [`ui/dr-ui/src/lib.rs:2070`](../../ui/dr-ui/src/lib.rs#L2070), [`ui/dr-ui/src/lib.rs:2084`](../../ui/dr-ui/src/lib.rs#L2084), [`ui/dr-ui/src/lib.rs:3080`](../../ui/dr-ui/src/lib.rs#L3080), [`ui/dr-ui/src/lib.rs:3139`](../../ui/dr-ui/src/lib.rs#L3139), [`ui/dr-ui/src/lib.rs:3211`](../../ui/dr-ui/src/lib.rs#L3211), [`ui/dr-ui/src/lib.rs:411`](../../ui/dr-ui/src/lib.rs#L411), [`ui/dr-ui/ui/app.slint:131`](../../ui/dr-ui/ui/app.slint#L131), [`ui/dr-ui/ui/app.slint:2445`](../../ui/dr-ui/ui/app.slint#L2445), [`ui/dr-ui/ui/app.slint:2460`](../../ui/dr-ui/ui/app.slint#L2460), [`ui/dr-ui/ui/app.slint:2780`](../../ui/dr-ui/ui/app.slint#L2780), [`ui/dr-ui/ui/history.slint:102`](../../ui/dr-ui/ui/history.slint#L102), [`ui/dr-ui/ui/history.slint:1`](../../ui/dr-ui/ui/history.slint#L1), [`ui/dr-ui/ui/history.slint:226`](../../ui/dr-ui/ui/history.slint#L226), [`ui/dr-ui/ui/history.slint:53`](../../ui/dr-ui/ui/history.slint#L53), [`ui/dr-ui/ui/widgets.slint:71`](../../ui/dr-ui/ui/widgets.slint#L71) | +| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:405`](../../core/dr-gpu/src/detail.rs#L405), [`core/dr-gpu/src/detail.rs:623`](../../core/dr-gpu/src/detail.rs#L623), [`core/dr-gpu/tests/detail_instances.rs:1`](../../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:1092`](../../core/dr-pipeline/src/graph.rs#L1092), [`core/dr-pipeline/src/graph.rs:115`](../../core/dr-pipeline/src/graph.rs#L115), [`core/dr-pipeline/src/graph.rs:247`](../../core/dr-pipeline/src/graph.rs#L247), [`core/dr-pipeline/src/operation.rs:330`](../../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:717`](../../core/dr-pipeline/src/operation.rs#L717), [`core/dr-pipeline/src/sidecar.rs:1071`](../../core/dr-pipeline/src/sidecar.rs#L1071), [`core/dr-pipeline/src/sidecar.rs:1125`](../../core/dr-pipeline/src/sidecar.rs#L1125), [`core/dr-pipeline/src/sidecar.rs:1155`](../../core/dr-pipeline/src/sidecar.rs#L1155), [`core/dr-pipeline/src/sidecar.rs:219`](../../core/dr-pipeline/src/sidecar.rs#L219), [`core/dr-pipeline/src/sidecar.rs:395`](../../core/dr-pipeline/src/sidecar.rs#L395), [`core/dr-pipeline/src/sidecar.rs:888`](../../core/dr-pipeline/src/sidecar.rs#L888), [`core/dr-pipeline/src/spot.rs:115`](../../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop/repairs.rs:114`](../../ui/dr-ui/src/develop/repairs.rs#L114), [`ui/dr-ui/src/develop/repairs.rs:11`](../../ui/dr-ui/src/develop/repairs.rs#L11), [`ui/dr-ui/src/develop/repairs.rs:197`](../../ui/dr-ui/src/develop/repairs.rs#L197), [`ui/dr-ui/src/develop/repairs.rs:211`](../../ui/dr-ui/src/develop/repairs.rs#L211), [`ui/dr-ui/src/develop/repairs.rs:49`](../../ui/dr-ui/src/develop/repairs.rs#L49), [`ui/dr-ui/src/develop/session.rs:212`](../../ui/dr-ui/src/develop/session.rs#L212), [`ui/dr-ui/src/labels.rs:80`](../../ui/dr-ui/src/labels.rs#L80), [`ui/dr-ui/src/lib.rs:2028`](../../ui/dr-ui/src/lib.rs#L2028), [`ui/dr-ui/src/lib.rs:3364`](../../ui/dr-ui/src/lib.rs#L3364), [`ui/dr-ui/src/lib.rs:453`](../../ui/dr-ui/src/lib.rs#L453), [`ui/dr-ui/src/spots_ui.rs:19`](../../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/app.slint:2301`](../../ui/dr-ui/ui/app.slint#L2301), [`ui/dr-ui/ui/app.slint:2503`](../../ui/dr-ui/ui/app.slint#L2503), [`ui/dr-ui/ui/app.slint:3079`](../../ui/dr-ui/ui/app.slint#L3079), [`ui/dr-ui/ui/icons.slint:366`](../../ui/dr-ui/ui/icons.slint#L366), [`ui/dr-ui/ui/session.slint:102`](../../ui/dr-ui/ui/session.slint#L102), [`ui/dr-ui/ui/session.slint:52`](../../ui/dr-ui/ui/session.slint#L52), [`ui/dr-ui/ui/spots.slint:191`](../../ui/dr-ui/ui/spots.slint#L191), [`ui/dr-ui/ui/spots.slint:49`](../../ui/dr-ui/ui/spots.slint#L49), [`ui/dr-ui/ui/spots.slint:6`](../../ui/dr-ui/ui/spots.slint#L6), [`ui/dr-ui/ui/spots.slint:91`](../../ui/dr-ui/ui/spots.slint#L91) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2366`](../../core/dr-gpu/src/adjust.rs#L2366), [`core/dr-gpu/src/adjust.rs:2443`](../../core/dr-gpu/src/adjust.rs#L2443), [`core/dr-gpu/src/adjust.rs:2528`](../../core/dr-gpu/src/adjust.rs#L2528), [`core/dr-gpu/src/adjust.rs:54`](../../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:798`](../../core/dr-gpu/src/adjust.rs#L798), [`core/dr-gpu/src/lib.rs:174`](../../core/dr-gpu/src/lib.rs#L174), [`core/dr-gpu/src/lib.rs:194`](../../core/dr-gpu/src/lib.rs#L194), [`core/dr-gpu/src/lib.rs:72`](../../core/dr-gpu/src/lib.rs#L72), [`core/dr-gpu/src/lib.rs:95`](../../core/dr-gpu/src/lib.rs#L95), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:922`](../../core/dr-pipeline/src/graph.rs#L922), [`core/dr-pipeline/src/graph.rs:952`](../../core/dr-pipeline/src/graph.rs#L952), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:659`](../../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/dehaze.rs:1`](../../core/dr-pipeline/src/ops/dehaze.rs#L1), [`core/dr-pipeline/src/ops/dehaze.rs:592`](../../core/dr-pipeline/src/ops/dehaze.rs#L592), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop/framing.rs:444`](../../ui/dr-ui/src/develop/framing.rs#L444), [`ui/dr-ui/src/develop/mod.rs:55`](../../ui/dr-ui/src/develop/mod.rs#L55), [`ui/dr-ui/src/develop/render.rs:140`](../../ui/dr-ui/src/develop/render.rs#L140), [`ui/dr-ui/src/develop/render.rs:940`](../../ui/dr-ui/src/develop/render.rs#L940), [`ui/dr-ui/src/develop/render.rs:974`](../../ui/dr-ui/src/develop/render.rs#L974), [`ui/dr-ui/src/lib.rs:100`](../../ui/dr-ui/src/lib.rs#L100), [`ui/dr-ui/src/lib.rs:1026`](../../ui/dr-ui/src/lib.rs#L1026), [`ui/dr-ui/src/lib.rs:1085`](../../ui/dr-ui/src/lib.rs#L1085) | | FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/local_contrast.rs:329`](../../core/dr-gpu/tests/local_contrast.rs#L329), [`core/dr-pipeline/src/ops/local_contrast.rs:447`](../../core/dr-pipeline/src/ops/local_contrast.rs#L447) | | FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../../core/dr-gpu/tests/zoom_resolution.rs#L216) | -| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:527`](../../core/dr-pipeline/src/operation.rs#L527), [`core/dr-types/src/colour.rs:1`](../../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:4060`](../../ui/dr-ui/src/develop.rs#L4060), [`ui/dr-ui/src/develop.rs:9225`](../../ui/dr-ui/src/develop.rs#L9225), [`ui/dr-ui/src/lib.rs:1974`](../../ui/dr-ui/src/lib.rs#L1974), [`ui/dr-ui/src/lib.rs:3755`](../../ui/dr-ui/src/lib.rs#L3755) | -| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:4114`](../../ui/dr-ui/src/develop.rs#L4114), [`ui/dr-ui/src/develop.rs:804`](../../ui/dr-ui/src/develop.rs#L804), [`ui/dr-ui/src/develop.rs:8895`](../../ui/dr-ui/src/develop.rs#L8895), [`ui/dr-ui/src/develop.rs:8927`](../../ui/dr-ui/src/develop.rs#L8927), [`ui/dr-ui/src/histogram.rs:1`](../../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:2108`](../../ui/dr-ui/src/lib.rs#L2108), [`ui/dr-ui/src/lib.rs:430`](../../ui/dr-ui/src/lib.rs#L430), [`ui/dr-ui/ui/histogram.slint:184`](../../ui/dr-ui/ui/histogram.slint#L184), [`ui/dr-ui/ui/histogram.slint:1`](../../ui/dr-ui/ui/histogram.slint#L1) | -| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:4006`](../../ui/dr-ui/src/develop.rs#L4006), [`ui/dr-ui/src/develop.rs:4060`](../../ui/dr-ui/src/develop.rs#L4060), [`ui/dr-ui/src/develop.rs:9225`](../../ui/dr-ui/src/develop.rs#L9225), [`ui/dr-ui/src/develop.rs:9271`](../../ui/dr-ui/src/develop.rs#L9271), [`ui/dr-ui/src/develop.rs:9290`](../../ui/dr-ui/src/develop.rs#L9290), [`ui/dr-ui/src/develop.rs:957`](../../ui/dr-ui/src/develop.rs#L957), [`ui/dr-ui/src/display_ui.rs:192`](../../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1910`](../../ui/dr-ui/src/lib.rs#L1910), [`ui/dr-ui/src/lib.rs:1974`](../../ui/dr-ui/src/lib.rs#L1974), [`ui/dr-ui/src/lib.rs:3662`](../../ui/dr-ui/src/lib.rs#L3662), [`ui/dr-ui/src/lib.rs:3755`](../../ui/dr-ui/src/lib.rs#L3755), [`ui/dr-ui/ui/app.slint:1981`](../../ui/dr-ui/ui/app.slint#L1981), [`ui/dr-ui/ui/app.slint:64`](../../ui/dr-ui/ui/app.slint#L64), [`ui/dr-ui/ui/settings.slint:165`](../../ui/dr-ui/ui/settings.slint#L165), [`ui/dr-ui/ui/settings.slint:968`](../../ui/dr-ui/ui/settings.slint#L968) | +| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:527`](../../core/dr-pipeline/src/operation.rs#L527), [`core/dr-types/src/colour.rs:1`](../../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop/render.rs:1316`](../../ui/dr-ui/src/develop/render.rs#L1316), [`ui/dr-ui/src/develop/render.rs:170`](../../ui/dr-ui/src/develop/render.rs#L170), [`ui/dr-ui/src/lib.rs:1974`](../../ui/dr-ui/src/lib.rs#L1974), [`ui/dr-ui/src/lib.rs:3755`](../../ui/dr-ui/src/lib.rs#L3755) | +| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop/render.rs:1106`](../../ui/dr-ui/src/develop/render.rs#L1106), [`ui/dr-ui/src/develop/render.rs:1138`](../../ui/dr-ui/src/develop/render.rs#L1138), [`ui/dr-ui/src/develop/render.rs:224`](../../ui/dr-ui/src/develop/render.rs#L224), [`ui/dr-ui/src/develop/session.rs:112`](../../ui/dr-ui/src/develop/session.rs#L112), [`ui/dr-ui/src/histogram.rs:1`](../../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:2108`](../../ui/dr-ui/src/lib.rs#L2108), [`ui/dr-ui/src/lib.rs:430`](../../ui/dr-ui/src/lib.rs#L430), [`ui/dr-ui/ui/histogram.slint:184`](../../ui/dr-ui/ui/histogram.slint#L184), [`ui/dr-ui/ui/histogram.slint:1`](../../ui/dr-ui/ui/histogram.slint#L1) | +| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop/render.rs:116`](../../ui/dr-ui/src/develop/render.rs#L116), [`ui/dr-ui/src/develop/render.rs:1316`](../../ui/dr-ui/src/develop/render.rs#L1316), [`ui/dr-ui/src/develop/render.rs:1362`](../../ui/dr-ui/src/develop/render.rs#L1362), [`ui/dr-ui/src/develop/render.rs:1381`](../../ui/dr-ui/src/develop/render.rs#L1381), [`ui/dr-ui/src/develop/render.rs:170`](../../ui/dr-ui/src/develop/render.rs#L170), [`ui/dr-ui/src/develop/session.rs:265`](../../ui/dr-ui/src/develop/session.rs#L265), [`ui/dr-ui/src/display_ui.rs:192`](../../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1910`](../../ui/dr-ui/src/lib.rs#L1910), [`ui/dr-ui/src/lib.rs:1974`](../../ui/dr-ui/src/lib.rs#L1974), [`ui/dr-ui/src/lib.rs:3662`](../../ui/dr-ui/src/lib.rs#L3662), [`ui/dr-ui/src/lib.rs:3755`](../../ui/dr-ui/src/lib.rs#L3755), [`ui/dr-ui/ui/app.slint:1981`](../../ui/dr-ui/ui/app.slint#L1981), [`ui/dr-ui/ui/app.slint:64`](../../ui/dr-ui/ui/app.slint#L64), [`ui/dr-ui/ui/settings.slint:165`](../../ui/dr-ui/ui/settings.slint#L165), [`ui/dr-ui/ui/settings.slint:968`](../../ui/dr-ui/ui/settings.slint#L968) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:157`](../../core/dr-export/src/lib.rs#L157), [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:57`](../../core/dr-export/src/lib.rs#L57), [`core/dr-gpu/src/adjust.rs:2676`](../../core/dr-gpu/src/adjust.rs#L2676), [`core/dr-pipeline/src/graph.rs:835`](../../core/dr-pipeline/src/graph.rs#L835), [`core/dr-pipeline/src/graph.rs:966`](../../core/dr-pipeline/src/graph.rs#L966), [`core/dr-pipeline/src/operation.rs:527`](../../core/dr-pipeline/src/operation.rs#L527), [`core/dr-types/src/colour.rs:1`](../../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:963`](../../core/dr-types/src/settings.rs#L963), [`ui/dr-ui/src/develop.rs:9290`](../../ui/dr-ui/src/develop.rs#L9290), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:157`](../../core/dr-export/src/lib.rs#L157), [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:57`](../../core/dr-export/src/lib.rs#L57), [`core/dr-gpu/src/adjust.rs:2676`](../../core/dr-gpu/src/adjust.rs#L2676), [`core/dr-pipeline/src/graph.rs:835`](../../core/dr-pipeline/src/graph.rs#L835), [`core/dr-pipeline/src/graph.rs:966`](../../core/dr-pipeline/src/graph.rs#L966), [`core/dr-pipeline/src/operation.rs:527`](../../core/dr-pipeline/src/operation.rs#L527), [`core/dr-types/src/colour.rs:1`](../../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:963`](../../core/dr-types/src/settings.rs#L963), [`ui/dr-ui/src/develop/render.rs:1381`](../../ui/dr-ui/src/develop/render.rs#L1381), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:186`](../../core/dr-export/src/lib.rs#L186), [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:136`](../../core/dr-export/src/size.rs#L136), [`core/dr-export/src/size.rs:1`](../../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../../core/dr-export/src/size.rs#L25), [`core/dr-export/src/size.rs:84`](../../core/dr-export/src/size.rs#L84), [`core/dr-types/src/settings.rs:1008`](../../core/dr-types/src/settings.rs#L1008), [`core/dr-types/src/settings.rs:1016`](../../core/dr-types/src/settings.rs#L1016), [`core/dr-types/src/settings.rs:1032`](../../core/dr-types/src/settings.rs#L1032), [`core/dr-types/src/settings.rs:1101`](../../core/dr-types/src/settings.rs#L1101), [`core/dr-types/src/settings.rs:1129`](../../core/dr-types/src/settings.rs#L1129), [`core/dr-types/src/settings.rs:1140`](../../core/dr-types/src/settings.rs#L1140), [`core/dr-types/src/settings.rs:1146`](../../core/dr-types/src/settings.rs#L1146), [`core/dr-types/src/settings.rs:1`](../../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:274`](../../ui/dr-ui/src/settings_ui.rs#L274), [`ui/dr-ui/src/settings_ui.rs:287`](../../ui/dr-ui/src/settings_ui.rs#L287), [`ui/dr-ui/src/settings_ui.rs:302`](../../ui/dr-ui/src/settings_ui.rs#L302), [`ui/dr-ui/src/settings_ui.rs:701`](../../ui/dr-ui/src/settings_ui.rs#L701), [`ui/dr-ui/ui/settings.slint:851`](../../ui/dr-ui/ui/settings.slint#L851), [`ui/dr-ui/ui/settings.slint:861`](../../ui/dr-ui/ui/settings.slint#L861) | | FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../../ui/dr-ui/src/settings_store.rs#L1) | | FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:607`](../../ui/dr-ui/src/lib.rs#L607), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:815`](../../ui/dr-ui/src/settings_ui.rs#L815) | | FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1005`](../../ui/dr-ui/src/export.rs#L1005), [`ui/dr-ui/src/export.rs:1`](../../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:268`](../../ui/dr-ui/src/lib.rs#L268), [`ui/dr-ui/src/lib.rs:2813`](../../ui/dr-ui/src/lib.rs#L2813), [`ui/dr-ui/src/lib.rs:607`](../../ui/dr-ui/src/lib.rs#L607), [`ui/dr-ui/src/lib.rs:653`](../../ui/dr-ui/src/lib.rs#L653), [`ui/dr-ui/src/lib.rs:680`](../../ui/dr-ui/src/lib.rs#L680), [`ui/dr-ui/src/library_ui.rs:4249`](../../ui/dr-ui/src/library_ui.rs#L4249), [`ui/dr-ui/src/library_ui.rs:721`](../../ui/dr-ui/src/library_ui.rs#L721), [`ui/dr-ui/src/library_ui.rs:8149`](../../ui/dr-ui/src/library_ui.rs#L8149), [`ui/dr-ui/src/library_ui.rs:821`](../../ui/dr-ui/src/library_ui.rs#L821), [`ui/dr-ui/src/library_ui.rs:8226`](../../ui/dr-ui/src/library_ui.rs#L8226), [`ui/dr-ui/src/library_ui.rs:8238`](../../ui/dr-ui/src/library_ui.rs#L8238), [`ui/dr-ui/src/library_ui.rs:878`](../../ui/dr-ui/src/library_ui.rs#L878), [`ui/dr-ui/ui/app.slint:1139`](../../ui/dr-ui/ui/app.slint#L1139), [`ui/dr-ui/ui/app.slint:1716`](../../ui/dr-ui/ui/app.slint#L1716), [`ui/dr-ui/ui/library.slint:1588`](../../ui/dr-ui/ui/library.slint#L1588), [`ui/dr-ui/ui/library.slint:4332`](../../ui/dr-ui/ui/library.slint#L4332) | -| FR-EXP-8 | [`core/dr-decode/src/lib.rs:366`](../../core/dr-decode/src/lib.rs#L366), [`core/dr-decode/src/lib.rs:390`](../../core/dr-decode/src/lib.rs#L390), [`core/dr-decode/src/lib.rs:404`](../../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/lib.rs:71`](../../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1233`](../../core/dr-decode/src/locate.rs#L1233), [`core/dr-decode/src/locate.rs:1292`](../../core/dr-decode/src/locate.rs#L1292), [`core/dr-decode/src/locate.rs:359`](../../core/dr-decode/src/locate.rs#L359), [`core/dr-decode/src/locate.rs:556`](../../core/dr-decode/src/locate.rs#L556), [`core/dr-decode/src/locate.rs:640`](../../core/dr-decode/src/locate.rs#L640), [`core/dr-decode/src/locate.rs:653`](../../core/dr-decode/src/locate.rs#L653), [`core/dr-decode/src/locate.rs:736`](../../core/dr-decode/src/locate.rs#L736), [`core/dr-export/examples/export.rs:99`](../../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:140`](../../core/dr-export/src/lib.rs#L140), [`core/dr-export/src/metadata.rs:1`](../../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:655`](../../core/dr-types/src/lib.rs#L655), [`core/dr-types/src/settings.rs:671`](../../core/dr-types/src/settings.rs#L671), [`ui/dr-ui/src/develop.rs:4581`](../../ui/dr-ui/src/develop.rs#L4581), [`ui/dr-ui/src/develop.rs:4693`](../../ui/dr-ui/src/develop.rs#L4693), [`ui/dr-ui/src/develop.rs:729`](../../ui/dr-ui/src/develop.rs#L729), [`ui/dr-ui/src/export.rs:474`](../../ui/dr-ui/src/export.rs#L474), [`ui/dr-ui/src/export.rs:641`](../../ui/dr-ui/src/export.rs#L641), [`ui/dr-ui/src/export.rs:687`](../../ui/dr-ui/src/export.rs#L687), [`ui/dr-ui/src/export.rs:715`](../../ui/dr-ui/src/export.rs#L715), [`ui/dr-ui/src/export.rs:839`](../../ui/dr-ui/src/export.rs#L839), [`ui/dr-ui/src/export.rs:857`](../../ui/dr-ui/src/export.rs#L857), [`ui/dr-ui/src/lib.rs:268`](../../ui/dr-ui/src/lib.rs#L268), [`ui/dr-ui/src/lib.rs:291`](../../ui/dr-ui/src/lib.rs#L291), [`ui/dr-ui/src/lib.rs:639`](../../ui/dr-ui/src/lib.rs#L639), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-9 | [`core/dr-decode/src/lib.rs:546`](../../core/dr-decode/src/lib.rs#L546), [`core/dr-export/src/lib.rs:132`](../../core/dr-export/src/lib.rs#L132), [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1134`](../../core/dr-gpu/src/adjust.rs#L1134), [`ui/dr-ui/examples/face_native.rs:1`](../../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/develop.rs:4714`](../../ui/dr-ui/src/develop.rs#L4714), [`ui/dr-ui/src/develop.rs:4752`](../../ui/dr-ui/src/develop.rs#L4752), [`ui/dr-ui/src/develop.rs:7111`](../../ui/dr-ui/src/develop.rs#L7111), [`ui/dr-ui/src/lib.rs:607`](../../ui/dr-ui/src/lib.rs#L607), [`ui/dr-ui/src/library.rs:3889`](../../ui/dr-ui/src/library.rs#L3889), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | +| FR-EXP-8 | [`core/dr-decode/src/lib.rs:366`](../../core/dr-decode/src/lib.rs#L366), [`core/dr-decode/src/lib.rs:390`](../../core/dr-decode/src/lib.rs#L390), [`core/dr-decode/src/lib.rs:404`](../../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/lib.rs:71`](../../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1233`](../../core/dr-decode/src/locate.rs#L1233), [`core/dr-decode/src/locate.rs:1292`](../../core/dr-decode/src/locate.rs#L1292), [`core/dr-decode/src/locate.rs:359`](../../core/dr-decode/src/locate.rs#L359), [`core/dr-decode/src/locate.rs:556`](../../core/dr-decode/src/locate.rs#L556), [`core/dr-decode/src/locate.rs:640`](../../core/dr-decode/src/locate.rs#L640), [`core/dr-decode/src/locate.rs:653`](../../core/dr-decode/src/locate.rs#L653), [`core/dr-decode/src/locate.rs:736`](../../core/dr-decode/src/locate.rs#L736), [`core/dr-export/examples/export.rs:99`](../../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:140`](../../core/dr-export/src/lib.rs#L140), [`core/dr-export/src/metadata.rs:1`](../../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:655`](../../core/dr-types/src/lib.rs#L655), [`core/dr-types/src/settings.rs:671`](../../core/dr-types/src/settings.rs#L671), [`ui/dr-ui/src/develop/session.rs:37`](../../ui/dr-ui/src/develop/session.rs#L37), [`ui/dr-ui/src/develop/session.rs:412`](../../ui/dr-ui/src/develop/session.rs#L412), [`ui/dr-ui/src/develop/session.rs:524`](../../ui/dr-ui/src/develop/session.rs#L524), [`ui/dr-ui/src/export.rs:474`](../../ui/dr-ui/src/export.rs#L474), [`ui/dr-ui/src/export.rs:641`](../../ui/dr-ui/src/export.rs#L641), [`ui/dr-ui/src/export.rs:687`](../../ui/dr-ui/src/export.rs#L687), [`ui/dr-ui/src/export.rs:715`](../../ui/dr-ui/src/export.rs#L715), [`ui/dr-ui/src/export.rs:839`](../../ui/dr-ui/src/export.rs#L839), [`ui/dr-ui/src/export.rs:857`](../../ui/dr-ui/src/export.rs#L857), [`ui/dr-ui/src/lib.rs:268`](../../ui/dr-ui/src/lib.rs#L268), [`ui/dr-ui/src/lib.rs:291`](../../ui/dr-ui/src/lib.rs#L291), [`ui/dr-ui/src/lib.rs:639`](../../ui/dr-ui/src/lib.rs#L639), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:546`](../../core/dr-decode/src/lib.rs#L546), [`core/dr-export/src/lib.rs:132`](../../core/dr-export/src/lib.rs#L132), [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1134`](../../core/dr-gpu/src/adjust.rs#L1134), [`ui/dr-ui/examples/face_native.rs:1`](../../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/develop/mask_ops.rs:1130`](../../ui/dr-ui/src/develop/mask_ops.rs#L1130), [`ui/dr-ui/src/develop/render.rs:546`](../../ui/dr-ui/src/develop/render.rs#L546), [`ui/dr-ui/src/develop/render.rs:584`](../../ui/dr-ui/src/develop/render.rs#L584), [`ui/dr-ui/src/lib.rs:607`](../../ui/dr-ui/src/lib.rs#L607), [`ui/dr-ui/src/library/sweep.rs:506`](../../ui/dr-ui/src/library/sweep.rs#L506), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | | FR-MRG-1 | [`core/dr-pano/src/align.rs:1`](../../core/dr-pano/src/align.rs#L1), [`core/dr-pano/src/lib.rs:1`](../../core/dr-pano/src/lib.rs#L1), [`ui/dr-ui/src/lib.rs:1576`](../../ui/dr-ui/src/lib.rs#L1576), [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/app.slint:1721`](../../ui/dr-ui/ui/app.slint#L1721), [`ui/dr-ui/ui/app.slint:829`](../../ui/dr-ui/ui/app.slint#L829), [`ui/dr-ui/ui/library.slint:1596`](../../ui/dr-ui/ui/library.slint#L1596), [`ui/dr-ui/ui/library.slint:4277`](../../ui/dr-ui/ui/library.slint#L4277), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1) | | FR-MRG-10 | [`core/dr-gpu/src/merge.rs:1`](../../core/dr-gpu/src/merge.rs#L1), [`core/dr-gpu/src/shaders/merge.wgsl:1`](../../core/dr-gpu/src/shaders/merge.wgsl#L1), [`core/dr-pano/src/lib.rs:1`](../../core/dr-pano/src/lib.rs#L1) | | FR-MRG-11 | [`core/dr-gpu/src/merge.rs:1`](../../core/dr-gpu/src/merge.rs#L1), [`core/dr-gpu/src/shaders/merge.wgsl:1`](../../core/dr-gpu/src/shaders/merge.wgsl#L1) | | FR-MRG-2 | [`core/dr-gpu/src/adjust.rs:104`](../../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:1157`](../../core/dr-gpu/src/adjust.rs#L1157), [`core/dr-gpu/src/adjust.rs:1276`](../../core/dr-gpu/src/adjust.rs#L1276), [`core/dr-gpu/src/adjust.rs:316`](../../core/dr-gpu/src/adjust.rs#L316), [`core/dr-pipeline/src/graph.rs:852`](../../core/dr-pipeline/src/graph.rs#L852), [`core/dr-pipeline/src/operation.rs:2183`](../../core/dr-pipeline/src/operation.rs#L2183), [`core/dr-pipeline/src/operation.rs:427`](../../core/dr-pipeline/src/operation.rs#L427), [`core/dr-pipeline/src/operation.rs:612`](../../core/dr-pipeline/src/operation.rs#L612), [`core/dr-pipeline/src/operation.rs:916`](../../core/dr-pipeline/src/operation.rs#L916), [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1) | | FR-MRG-3 | [`core/dr-decode/examples/linear_dng.rs:1`](../../core/dr-decode/examples/linear_dng.rs#L1), [`core/dr-decode/src/lib.rs:137`](../../core/dr-decode/src/lib.rs#L137), [`core/dr-decode/src/lib.rs:144`](../../core/dr-decode/src/lib.rs#L144), [`core/dr-decode/src/lib.rs:598`](../../core/dr-decode/src/lib.rs#L598), [`core/dr-decode/src/profile.rs:395`](../../core/dr-decode/src/profile.rs#L395), [`core/dr-export/src/dng.rs:1`](../../core/dr-export/src/dng.rs#L1), [`core/dr-gpu/src/demosaic.rs:254`](../../core/dr-gpu/src/demosaic.rs#L254), [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1) | -| FR-MRG-4 | [`core/dr-export/src/inscribed.rs:1`](../../core/dr-export/src/inscribed.rs#L1), [`core/dr-pano/src/fill.rs:1`](../../core/dr-pano/src/fill.rs#L1), [`core/dr-pano/src/migan.rs:1`](../../core/dr-pano/src/migan.rs#L1), [`core/dr-pano/src/projection.rs:1`](../../core/dr-pano/src/projection.rs#L1), [`ui/dr-ui/src/library.rs:4457`](../../ui/dr-ui/src/library.rs#L4457), [`ui/dr-ui/src/library.rs:4461`](../../ui/dr-ui/src/library.rs#L4461), [`ui/dr-ui/src/merge.rs:143`](../../ui/dr-ui/src/merge.rs#L143), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1), [`ui/dr-ui/ui/merge.slint:46`](../../ui/dr-ui/ui/merge.slint#L46) | +| FR-MRG-4 | [`core/dr-export/src/inscribed.rs:1`](../../core/dr-export/src/inscribed.rs#L1), [`core/dr-pano/src/fill.rs:1`](../../core/dr-pano/src/fill.rs#L1), [`core/dr-pano/src/migan.rs:1`](../../core/dr-pano/src/migan.rs#L1), [`core/dr-pano/src/projection.rs:1`](../../core/dr-pano/src/projection.rs#L1), [`ui/dr-ui/src/library/paths.rs:344`](../../ui/dr-ui/src/library/paths.rs#L344), [`ui/dr-ui/src/library/paths.rs:348`](../../ui/dr-ui/src/library/paths.rs#L348), [`ui/dr-ui/src/merge.rs:143`](../../ui/dr-ui/src/merge.rs#L143), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1), [`ui/dr-ui/ui/merge.slint:46`](../../ui/dr-ui/ui/merge.slint#L46) | | FR-MRG-5 | [`core/dr-pano/src/align.rs:1`](../../core/dr-pano/src/align.rs#L1), [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1) | | FR-MRG-6 | [`core/dr-pipeline/src/sidecar.rs:1033`](../../core/dr-pipeline/src/sidecar.rs#L1033), [`core/dr-pipeline/src/sidecar.rs:113`](../../core/dr-pipeline/src/sidecar.rs#L113), [`core/dr-pipeline/src/sidecar.rs:123`](../../core/dr-pipeline/src/sidecar.rs#L123), [`core/dr-pipeline/src/sidecar.rs:2241`](../../core/dr-pipeline/src/sidecar.rs#L2241), [`core/dr-pipeline/src/sidecar.rs:841`](../../core/dr-pipeline/src/sidecar.rs#L841), [`ui/dr-ui/src/merge.rs:842`](../../ui/dr-ui/src/merge.rs#L842) | | FR-MRG-7 | [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/app.slint:829`](../../ui/dr-ui/ui/app.slint#L829), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1) | | FR-MRG-8 | [`core/dr-pano/src/xfeat.rs:1`](../../core/dr-pano/src/xfeat.rs#L1), [`core/dr-segment/examples/onnx_probe.rs:1`](../../core/dr-segment/examples/onnx_probe.rs#L1) | | FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/provider.rs:1`](../../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:375`](../../core/dr-sync/src/account.rs#L375), [`ui/dr-ui/src/launch.rs:316`](../../ui/dr-ui/src/launch.rs#L316), [`ui/dr-ui/src/launch.rs:61`](../../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:418`](../../ui/dr-ui/src/launch_ui.rs#L418) | -| FR-NC-10 | [`core/dr-sync/src/account.rs:240`](../../core/dr-sync/src/account.rs#L240), [`ui/dr-ui/src/export.rs:1`](../../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:680`](../../ui/dr-ui/src/lib.rs#L680), [`ui/dr-ui/src/library.rs:1234`](../../ui/dr-ui/src/library.rs#L1234), [`ui/dr-ui/src/library.rs:2527`](../../ui/dr-ui/src/library.rs#L2527), [`ui/dr-ui/src/library.rs:653`](../../ui/dr-ui/src/library.rs#L653), [`ui/dr-ui/src/library.rs:980`](../../ui/dr-ui/src/library.rs#L980), [`ui/dr-ui/src/library_ui.rs:2057`](../../ui/dr-ui/src/library_ui.rs#L2057), [`ui/dr-ui/src/library_ui.rs:4249`](../../ui/dr-ui/src/library_ui.rs#L4249), [`ui/dr-ui/src/library_ui.rs:662`](../../ui/dr-ui/src/library_ui.rs#L662), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-NC-10 | [`core/dr-sync/src/account.rs:240`](../../core/dr-sync/src/account.rs#L240), [`ui/dr-ui/src/export.rs:1`](../../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:680`](../../ui/dr-ui/src/lib.rs#L680), [`ui/dr-ui/src/library/paths.rs:69`](../../ui/dr-ui/src/library/paths.rs#L69), [`ui/dr-ui/src/library/sidecar.rs:100`](../../ui/dr-ui/src/library/sidecar.rs#L100), [`ui/dr-ui/src/library/sidecar.rs:427`](../../ui/dr-ui/src/library/sidecar.rs#L427), [`ui/dr-ui/src/library/thumbnails_fetch.rs:449`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L449), [`ui/dr-ui/src/library_ui.rs:2057`](../../ui/dr-ui/src/library_ui.rs#L2057), [`ui/dr-ui/src/library_ui.rs:4249`](../../ui/dr-ui/src/library_ui.rs#L4249), [`ui/dr-ui/src/library_ui.rs:662`](../../ui/dr-ui/src/library_ui.rs#L662), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:1049`](../../core/dr-sync-nextcloud/src/lib.rs#L1049), [`core/dr-sync-nextcloud/src/lib.rs:40`](../../core/dr-sync-nextcloud/src/lib.rs#L40), [`core/dr-sync-nextcloud/src/provider.rs:1`](../../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:87`](../../core/dr-sync/src/account.rs#L87), [`core/dr-sync/src/lib.rs:218`](../../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/lib.rs:51`](../../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:106`](../../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../../ui/dr-ui/src/remote.rs#L1) | | FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:197`](../../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:1`](../../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:73`](../../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync/src/provider.rs:106`](../../core/dr-sync/src/provider.rs#L106), [`ui/dr-ui/src/launch_ui.rs:317`](../../ui/dr-ui/src/launch_ui.rs#L317), [`ui/dr-ui/src/remote.rs:1`](../../ui/dr-ui/src/remote.rs#L1) | | FR-NC-2 | [`core/dr-sync/src/account.rs:1`](../../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:318`](../../core/dr-sync/src/account.rs#L318), [`core/dr-sync/src/account.rs:375`](../../core/dr-sync/src/account.rs#L375), [`core/dr-sync/src/account.rs:59`](../../core/dr-sync/src/account.rs#L59), [`platform/dr-plat/src/secrets.rs:82`](../../platform/dr-plat/src/secrets.rs#L82) | -| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../../core/dr-thumbs/src/lib.rs#L1), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/library.rs:1`](../../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:3940`](../../ui/dr-ui/src/library.rs#L3940), [`ui/dr-ui/src/library_ui.rs:1`](../../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:4515`](../../ui/dr-ui/src/library_ui.rs#L4515), [`ui/dr-ui/src/library_ui.rs:6027`](../../ui/dr-ui/src/library_ui.rs#L6027), [`ui/dr-ui/ui/app.slint:387`](../../ui/dr-ui/ui/app.slint#L387), [`ui/dr-ui/ui/settings.slint:435`](../../ui/dr-ui/ui/settings.slint#L435), [`ui/dr-ui/ui/settings.slint:73`](../../ui/dr-ui/ui/settings.slint#L73) | +| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../../core/dr-thumbs/src/lib.rs#L1), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1), [`ui/dr-ui/src/library/sweep.rs:557`](../../ui/dr-ui/src/library/sweep.rs#L557), [`ui/dr-ui/src/library_ui.rs:1`](../../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:4515`](../../ui/dr-ui/src/library_ui.rs#L4515), [`ui/dr-ui/src/library_ui.rs:6027`](../../ui/dr-ui/src/library_ui.rs#L6027), [`ui/dr-ui/ui/app.slint:387`](../../ui/dr-ui/ui/app.slint#L387), [`ui/dr-ui/ui/settings.slint:435`](../../ui/dr-ui/ui/settings.slint#L435), [`ui/dr-ui/ui/settings.slint:73`](../../ui/dr-ui/ui/settings.slint#L73) | | FR-NC-4 | [`core/dr-sync-folder/src/lib.rs:197`](../../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-nextcloud/src/propfind.rs:103`](../../core/dr-sync-nextcloud/src/propfind.rs#L103), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:218`](../../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/scan.rs:129`](../../core/dr-sync/src/scan.rs#L129), [`ui/dr-ui/src/launch.rs:61`](../../ui/dr-ui/src/launch.rs#L61) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:488`](../../ui/dr-ui/src/import.rs#L488) | | FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../../ui/dr-ui/src/activity.rs#L1) | -| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1182`](../../core/dr-catalog/src/schema.rs#L1182), [`core/dr-catalog/src/schema.rs:1637`](../../core/dr-catalog/src/schema.rs#L1637), [`core/dr-sync-folder/src/borrow.rs:1`](../../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-types/src/selector.rs:1`](../../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2940`](../../ui/dr-ui/src/collections_ui.rs#L2940), [`ui/dr-ui/src/collections_ui.rs:3182`](../../ui/dr-ui/src/collections_ui.rs#L3182), [`ui/dr-ui/src/collections_ui.rs:4083`](../../ui/dr-ui/src/collections_ui.rs#L4083), [`ui/dr-ui/src/collections_ui.rs:785`](../../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/lib.rs:1350`](../../ui/dr-ui/src/lib.rs#L1350), [`ui/dr-ui/src/lib.rs:2487`](../../ui/dr-ui/src/lib.rs#L2487), [`ui/dr-ui/src/lib.rs:2558`](../../ui/dr-ui/src/lib.rs#L2558), [`ui/dr-ui/src/lib.rs:3852`](../../ui/dr-ui/src/lib.rs#L3852), [`ui/dr-ui/src/lib.rs:3853`](../../ui/dr-ui/src/lib.rs#L3853), [`ui/dr-ui/src/lib.rs:3892`](../../ui/dr-ui/src/lib.rs#L3892), [`ui/dr-ui/src/library.rs:2173`](../../ui/dr-ui/src/library.rs#L2173), [`ui/dr-ui/src/library.rs:2196`](../../ui/dr-ui/src/library.rs#L2196), [`ui/dr-ui/src/library.rs:2471`](../../ui/dr-ui/src/library.rs#L2471), [`ui/dr-ui/src/library.rs:2652`](../../ui/dr-ui/src/library.rs#L2652), [`ui/dr-ui/src/library.rs:2789`](../../ui/dr-ui/src/library.rs#L2789), [`ui/dr-ui/src/library.rs:2804`](../../ui/dr-ui/src/library.rs#L2804), [`ui/dr-ui/src/library_ui.rs:1482`](../../ui/dr-ui/src/library_ui.rs#L1482), [`ui/dr-ui/src/library_ui.rs:1555`](../../ui/dr-ui/src/library_ui.rs#L1555), [`ui/dr-ui/src/library_ui.rs:1656`](../../ui/dr-ui/src/library_ui.rs#L1656), [`ui/dr-ui/src/library_ui.rs:1711`](../../ui/dr-ui/src/library_ui.rs#L1711), [`ui/dr-ui/src/library_ui.rs:1846`](../../ui/dr-ui/src/library_ui.rs#L1846), [`ui/dr-ui/src/library_ui.rs:1964`](../../ui/dr-ui/src/library_ui.rs#L1964), [`ui/dr-ui/src/library_ui.rs:2731`](../../ui/dr-ui/src/library_ui.rs#L2731), [`ui/dr-ui/src/library_ui.rs:344`](../../ui/dr-ui/src/library_ui.rs#L344), [`ui/dr-ui/src/library_ui.rs:355`](../../ui/dr-ui/src/library_ui.rs#L355), [`ui/dr-ui/src/library_ui.rs:370`](../../ui/dr-ui/src/library_ui.rs#L370), [`ui/dr-ui/src/library_ui.rs:379`](../../ui/dr-ui/src/library_ui.rs#L379), [`ui/dr-ui/src/library_ui.rs:387`](../../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:517`](../../ui/dr-ui/src/library_ui.rs#L517), [`ui/dr-ui/src/library_ui.rs:527`](../../ui/dr-ui/src/library_ui.rs#L527), [`ui/dr-ui/src/library_ui.rs:542`](../../ui/dr-ui/src/library_ui.rs#L542), [`ui/dr-ui/src/library_ui.rs:589`](../../ui/dr-ui/src/library_ui.rs#L589), [`ui/dr-ui/src/library_ui.rs:604`](../../ui/dr-ui/src/library_ui.rs#L604), [`ui/dr-ui/src/library_ui.rs:678`](../../ui/dr-ui/src/library_ui.rs#L678), [`ui/dr-ui/src/library_ui.rs:6881`](../../ui/dr-ui/src/library_ui.rs#L6881), [`ui/dr-ui/src/library_ui.rs:6899`](../../ui/dr-ui/src/library_ui.rs#L6899), [`ui/dr-ui/src/library_ui.rs:709`](../../ui/dr-ui/src/library_ui.rs#L709), [`ui/dr-ui/src/library_ui.rs:721`](../../ui/dr-ui/src/library_ui.rs#L721), [`ui/dr-ui/src/settings_store.rs:1`](../../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:157`](../../ui/dr-ui/src/settings_ui.rs#L157), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:524`](../../ui/dr-ui/src/settings_ui.rs#L524), [`ui/dr-ui/ui/app.slint:1610`](../../ui/dr-ui/ui/app.slint#L1610), [`ui/dr-ui/ui/app.slint:3163`](../../ui/dr-ui/ui/app.slint#L3163), [`ui/dr-ui/ui/app.slint:541`](../../ui/dr-ui/ui/app.slint#L541), [`ui/dr-ui/ui/app.slint:549`](../../ui/dr-ui/ui/app.slint#L549), [`ui/dr-ui/ui/app.slint:555`](../../ui/dr-ui/ui/app.slint#L555), [`ui/dr-ui/ui/collections.slint:1037`](../../ui/dr-ui/ui/collections.slint#L1037), [`ui/dr-ui/ui/collections.slint:1048`](../../ui/dr-ui/ui/collections.slint#L1048), [`ui/dr-ui/ui/collections.slint:1127`](../../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:272`](../../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:424`](../../ui/dr-ui/ui/collections.slint#L424), [`ui/dr-ui/ui/collections.slint:433`](../../ui/dr-ui/ui/collections.slint#L433), [`ui/dr-ui/ui/collections.slint:436`](../../ui/dr-ui/ui/collections.slint#L436), [`ui/dr-ui/ui/collections.slint:482`](../../ui/dr-ui/ui/collections.slint#L482), [`ui/dr-ui/ui/collections.slint:52`](../../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:720`](../../ui/dr-ui/ui/collections.slint#L720), [`ui/dr-ui/ui/collections.slint:877`](../../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/collections.slint:91`](../../ui/dr-ui/ui/collections.slint#L91), [`ui/dr-ui/ui/icons.slint:262`](../../ui/dr-ui/ui/icons.slint#L262), [`ui/dr-ui/ui/settings.slint:114`](../../ui/dr-ui/ui/settings.slint#L114), [`ui/dr-ui/ui/settings.slint:656`](../../ui/dr-ui/ui/settings.slint#L656) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1182`](../../core/dr-catalog/src/schema.rs#L1182), [`core/dr-catalog/src/schema.rs:1637`](../../core/dr-catalog/src/schema.rs#L1637), [`core/dr-sync-folder/src/borrow.rs:1`](../../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-types/src/selector.rs:1`](../../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2940`](../../ui/dr-ui/src/collections_ui.rs#L2940), [`ui/dr-ui/src/collections_ui.rs:3182`](../../ui/dr-ui/src/collections_ui.rs#L3182), [`ui/dr-ui/src/collections_ui.rs:4083`](../../ui/dr-ui/src/collections_ui.rs#L4083), [`ui/dr-ui/src/collections_ui.rs:785`](../../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/lib.rs:1350`](../../ui/dr-ui/src/lib.rs#L1350), [`ui/dr-ui/src/lib.rs:2487`](../../ui/dr-ui/src/lib.rs#L2487), [`ui/dr-ui/src/lib.rs:2558`](../../ui/dr-ui/src/lib.rs#L2558), [`ui/dr-ui/src/lib.rs:3852`](../../ui/dr-ui/src/lib.rs#L3852), [`ui/dr-ui/src/lib.rs:3853`](../../ui/dr-ui/src/lib.rs#L3853), [`ui/dr-ui/src/lib.rs:3892`](../../ui/dr-ui/src/lib.rs#L3892), [`ui/dr-ui/src/library/thumbnails_fetch.rs:118`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L118), [`ui/dr-ui/src/library/thumbnails_fetch.rs:393`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L393), [`ui/dr-ui/src/library/thumbnails_fetch.rs:574`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L574), [`ui/dr-ui/src/library/thumbnails_fetch.rs:711`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L711), [`ui/dr-ui/src/library/thumbnails_fetch.rs:726`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L726), [`ui/dr-ui/src/library/thumbnails_fetch.rs:95`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L95), [`ui/dr-ui/src/library_ui.rs:1482`](../../ui/dr-ui/src/library_ui.rs#L1482), [`ui/dr-ui/src/library_ui.rs:1555`](../../ui/dr-ui/src/library_ui.rs#L1555), [`ui/dr-ui/src/library_ui.rs:1656`](../../ui/dr-ui/src/library_ui.rs#L1656), [`ui/dr-ui/src/library_ui.rs:1711`](../../ui/dr-ui/src/library_ui.rs#L1711), [`ui/dr-ui/src/library_ui.rs:1846`](../../ui/dr-ui/src/library_ui.rs#L1846), [`ui/dr-ui/src/library_ui.rs:1964`](../../ui/dr-ui/src/library_ui.rs#L1964), [`ui/dr-ui/src/library_ui.rs:2731`](../../ui/dr-ui/src/library_ui.rs#L2731), [`ui/dr-ui/src/library_ui.rs:344`](../../ui/dr-ui/src/library_ui.rs#L344), [`ui/dr-ui/src/library_ui.rs:355`](../../ui/dr-ui/src/library_ui.rs#L355), [`ui/dr-ui/src/library_ui.rs:370`](../../ui/dr-ui/src/library_ui.rs#L370), [`ui/dr-ui/src/library_ui.rs:379`](../../ui/dr-ui/src/library_ui.rs#L379), [`ui/dr-ui/src/library_ui.rs:387`](../../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:517`](../../ui/dr-ui/src/library_ui.rs#L517), [`ui/dr-ui/src/library_ui.rs:527`](../../ui/dr-ui/src/library_ui.rs#L527), [`ui/dr-ui/src/library_ui.rs:542`](../../ui/dr-ui/src/library_ui.rs#L542), [`ui/dr-ui/src/library_ui.rs:589`](../../ui/dr-ui/src/library_ui.rs#L589), [`ui/dr-ui/src/library_ui.rs:604`](../../ui/dr-ui/src/library_ui.rs#L604), [`ui/dr-ui/src/library_ui.rs:678`](../../ui/dr-ui/src/library_ui.rs#L678), [`ui/dr-ui/src/library_ui.rs:6881`](../../ui/dr-ui/src/library_ui.rs#L6881), [`ui/dr-ui/src/library_ui.rs:6899`](../../ui/dr-ui/src/library_ui.rs#L6899), [`ui/dr-ui/src/library_ui.rs:709`](../../ui/dr-ui/src/library_ui.rs#L709), [`ui/dr-ui/src/library_ui.rs:721`](../../ui/dr-ui/src/library_ui.rs#L721), [`ui/dr-ui/src/settings_store.rs:1`](../../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:157`](../../ui/dr-ui/src/settings_ui.rs#L157), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:524`](../../ui/dr-ui/src/settings_ui.rs#L524), [`ui/dr-ui/ui/app.slint:1610`](../../ui/dr-ui/ui/app.slint#L1610), [`ui/dr-ui/ui/app.slint:3163`](../../ui/dr-ui/ui/app.slint#L3163), [`ui/dr-ui/ui/app.slint:541`](../../ui/dr-ui/ui/app.slint#L541), [`ui/dr-ui/ui/app.slint:549`](../../ui/dr-ui/ui/app.slint#L549), [`ui/dr-ui/ui/app.slint:555`](../../ui/dr-ui/ui/app.slint#L555), [`ui/dr-ui/ui/collections.slint:1037`](../../ui/dr-ui/ui/collections.slint#L1037), [`ui/dr-ui/ui/collections.slint:1048`](../../ui/dr-ui/ui/collections.slint#L1048), [`ui/dr-ui/ui/collections.slint:1127`](../../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:272`](../../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:424`](../../ui/dr-ui/ui/collections.slint#L424), [`ui/dr-ui/ui/collections.slint:433`](../../ui/dr-ui/ui/collections.slint#L433), [`ui/dr-ui/ui/collections.slint:436`](../../ui/dr-ui/ui/collections.slint#L436), [`ui/dr-ui/ui/collections.slint:482`](../../ui/dr-ui/ui/collections.slint#L482), [`ui/dr-ui/ui/collections.slint:52`](../../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:720`](../../ui/dr-ui/ui/collections.slint#L720), [`ui/dr-ui/ui/collections.slint:877`](../../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/collections.slint:91`](../../ui/dr-ui/ui/collections.slint#L91), [`ui/dr-ui/ui/icons.slint:262`](../../ui/dr-ui/ui/icons.slint#L262), [`ui/dr-ui/ui/settings.slint:114`](../../ui/dr-ui/ui/settings.slint#L114), [`ui/dr-ui/ui/settings.slint:656`](../../ui/dr-ui/ui/settings.slint#L656) | | FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1711`](../../ui/dr-ui/src/library_ui.rs#L1711), [`ui/dr-ui/src/memory.rs:1`](../../ui/dr-ui/src/memory.rs#L1) | -| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:172`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L172), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/capability.rs:41`](../../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:122`](../../core/dr-types/src/lib.rs#L122), [`core/dr-types/src/lib.rs:204`](../../core/dr-types/src/lib.rs#L204), [`ui/dr-ui/src/activity.rs:1`](../../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:4083`](../../ui/dr-ui/src/collections_ui.rs#L4083), [`ui/dr-ui/src/collections_ui.rs:785`](../../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/derived_sync.rs:1042`](../../ui/dr-ui/src/derived_sync.rs#L1042), [`ui/dr-ui/src/derived_sync.rs:642`](../../ui/dr-ui/src/derived_sync.rs#L642), [`ui/dr-ui/src/library.rs:1084`](../../ui/dr-ui/src/library.rs#L1084), [`ui/dr-ui/src/library.rs:2293`](../../ui/dr-ui/src/library.rs#L2293), [`ui/dr-ui/src/library.rs:2383`](../../ui/dr-ui/src/library.rs#L2383), [`ui/dr-ui/src/library.rs:4060`](../../ui/dr-ui/src/library.rs#L4060), [`ui/dr-ui/src/library_ui.rs:1482`](../../ui/dr-ui/src/library_ui.rs#L1482), [`ui/dr-ui/src/library_ui.rs:1555`](../../ui/dr-ui/src/library_ui.rs#L1555), [`ui/dr-ui/src/library_ui.rs:1754`](../../ui/dr-ui/src/library_ui.rs#L1754), [`ui/dr-ui/src/remote.rs:40`](../../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/src/repairs.rs:1140`](../../ui/dr-ui/src/repairs.rs#L1140), [`ui/dr-ui/ui/collections.slint:272`](../../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:877`](../../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/icons.slint:262`](../../ui/dr-ui/ui/icons.slint#L262) | +| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:172`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L172), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/capability.rs:41`](../../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:122`](../../core/dr-types/src/lib.rs#L122), [`core/dr-types/src/lib.rs:204`](../../core/dr-types/src/lib.rs#L204), [`ui/dr-ui/src/activity.rs:1`](../../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:4083`](../../ui/dr-ui/src/collections_ui.rs#L4083), [`ui/dr-ui/src/collections_ui.rs:785`](../../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/derived_sync.rs:1042`](../../ui/dr-ui/src/derived_sync.rs#L1042), [`ui/dr-ui/src/derived_sync.rs:642`](../../ui/dr-ui/src/derived_sync.rs#L642), [`ui/dr-ui/src/library/sidecar.rs:531`](../../ui/dr-ui/src/library/sidecar.rs#L531), [`ui/dr-ui/src/library/sweep.rs:677`](../../ui/dr-ui/src/library/sweep.rs#L677), [`ui/dr-ui/src/library/thumbnails_fetch.rs:215`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L215), [`ui/dr-ui/src/library/thumbnails_fetch.rs:305`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L305), [`ui/dr-ui/src/library_ui.rs:1482`](../../ui/dr-ui/src/library_ui.rs#L1482), [`ui/dr-ui/src/library_ui.rs:1555`](../../ui/dr-ui/src/library_ui.rs#L1555), [`ui/dr-ui/src/library_ui.rs:1754`](../../ui/dr-ui/src/library_ui.rs#L1754), [`ui/dr-ui/src/remote.rs:40`](../../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/src/repairs.rs:1140`](../../ui/dr-ui/src/repairs.rs#L1140), [`ui/dr-ui/ui/collections.slint:272`](../../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:877`](../../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/icons.slint:262`](../../ui/dr-ui/ui/icons.slint#L262) | | FR-NC-6d | [`core/dr-sync-folder/src/borrow.rs:1`](../../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:1`](../../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/vfs.rs:1`](../../core/dr-sync-folder/src/vfs.rs#L1) | -| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:120`](../../core/dr-sync-nextcloud/src/lib.rs#L120), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3940`](../../ui/dr-ui/src/library.rs#L3940), [`ui/dr-ui/src/library_ui.rs:4515`](../../ui/dr-ui/src/library_ui.rs#L4515), [`ui/dr-ui/ui/settings.slint:435`](../../ui/dr-ui/ui/settings.slint#L435) | +| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:120`](../../core/dr-sync-nextcloud/src/lib.rs#L120), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library/sweep.rs:557`](../../ui/dr-ui/src/library/sweep.rs#L557), [`ui/dr-ui/src/library_ui.rs:4515`](../../ui/dr-ui/src/library_ui.rs#L4515), [`ui/dr-ui/ui/settings.slint:435`](../../ui/dr-ui/ui/settings.slint#L435) | | FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:377`](../../core/dr-types/src/settings.rs#L377), [`ui/dr-ui/src/import.rs:1`](../../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1559`](../../ui/dr-ui/src/lib.rs#L1559), [`ui/dr-ui/ui/import.slint:5`](../../ui/dr-ui/ui/import.slint#L5) | | FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1559`](../../ui/dr-ui/src/lib.rs#L1559) | -| FR-NC-8 | [`core/dr-catalog/src/rating.rs:204`](../../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:327`](../../core/dr-catalog/src/schema.rs#L327), [`core/dr-pipeline/src/sidecar.rs:137`](../../core/dr-pipeline/src/sidecar.rs#L137), [`core/dr-pipeline/src/sidecar.rs:2991`](../../core/dr-pipeline/src/sidecar.rs#L2991), [`core/dr-pipeline/src/sidecar.rs:660`](../../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:735`](../../core/dr-pipeline/src/sidecar.rs#L735), [`core/dr-pipeline/src/sidecar.rs:96`](../../core/dr-pipeline/src/sidecar.rs#L96), [`ui/dr-ui/src/lib.rs:2457`](../../ui/dr-ui/src/lib.rs#L2457), [`ui/dr-ui/src/library.rs:1126`](../../ui/dr-ui/src/library.rs#L1126), [`ui/dr-ui/src/library.rs:2582`](../../ui/dr-ui/src/library.rs#L2582), [`ui/dr-ui/src/library.rs:564`](../../ui/dr-ui/src/library.rs#L564), [`ui/dr-ui/src/library.rs:7554`](../../ui/dr-ui/src/library.rs#L7554), [`ui/dr-ui/src/library.rs:824`](../../ui/dr-ui/src/library.rs#L824), [`ui/dr-ui/src/library_ui.rs:630`](../../ui/dr-ui/src/library_ui.rs#L630), [`ui/dr-ui/src/presets.rs:288`](../../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../../ui/dr-ui/src/presets.rs#L336) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:204`](../../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:1125`](../../core/dr-catalog/src/schema.rs#L1125), [`core/dr-catalog/src/schema.rs:327`](../../core/dr-catalog/src/schema.rs#L327), [`core/dr-catalog/src/schema.rs:740`](../../core/dr-catalog/src/schema.rs#L740), [`core/dr-catalog/src/sync.rs:1`](../../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:192`](../../core/dr-pipeline/src/sidecar.rs#L192), [`core/dr-pipeline/src/sidecar.rs:219`](../../core/dr-pipeline/src/sidecar.rs#L219), [`core/dr-pipeline/src/sidecar.rs:2520`](../../core/dr-pipeline/src/sidecar.rs#L2520), [`core/dr-pipeline/src/sidecar.rs:2757`](../../core/dr-pipeline/src/sidecar.rs#L2757), [`core/dr-pipeline/src/sidecar.rs:2991`](../../core/dr-pipeline/src/sidecar.rs#L2991), [`core/dr-pipeline/src/sidecar.rs:395`](../../core/dr-pipeline/src/sidecar.rs#L395), [`core/dr-pipeline/src/sidecar.rs:493`](../../core/dr-pipeline/src/sidecar.rs#L493), [`core/dr-pipeline/src/sidecar.rs:660`](../../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:710`](../../core/dr-pipeline/src/sidecar.rs#L710), [`core/dr-pipeline/src/sidecar.rs:735`](../../core/dr-pipeline/src/sidecar.rs#L735), [`core/dr-pipeline/src/spot.rs:245`](../../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/mask_sidecar.rs:1075`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1075), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:139`](../../core/dr-sync-nextcloud/src/lib.rs#L139), [`core/dr-sync/src/scan.rs:31`](../../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../../core/dr-sync/src/scan.rs#L926), [`core/dr-xmp/src/lib.rs:733`](../../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../../core/dr-xmp/src/lib.rs#L746), [`ui/dr-ui/src/derived_sync.rs:1070`](../../ui/dr-ui/src/derived_sync.rs#L1070), [`ui/dr-ui/src/derived_sync.rs:613`](../../ui/dr-ui/src/derived_sync.rs#L613), [`ui/dr-ui/src/derived_sync.rs:642`](../../ui/dr-ui/src/derived_sync.rs#L642), [`ui/dr-ui/src/derived_sync.rs:675`](../../ui/dr-ui/src/derived_sync.rs#L675), [`ui/dr-ui/src/derived_sync.rs:821`](../../ui/dr-ui/src/derived_sync.rs#L821), [`ui/dr-ui/src/library.rs:1126`](../../ui/dr-ui/src/library.rs#L1126), [`ui/dr-ui/src/library.rs:1146`](../../ui/dr-ui/src/library.rs#L1146), [`ui/dr-ui/src/library.rs:1455`](../../ui/dr-ui/src/library.rs#L1455), [`ui/dr-ui/src/library.rs:1490`](../../ui/dr-ui/src/library.rs#L1490), [`ui/dr-ui/src/library.rs:2582`](../../ui/dr-ui/src/library.rs#L2582), [`ui/dr-ui/src/library.rs:7386`](../../ui/dr-ui/src/library.rs#L7386), [`ui/dr-ui/src/library.rs:73`](../../ui/dr-ui/src/library.rs#L73), [`ui/dr-ui/src/library.rs:7554`](../../ui/dr-ui/src/library.rs#L7554), [`ui/dr-ui/src/library.rs:824`](../../ui/dr-ui/src/library.rs#L824), [`ui/dr-ui/src/library.rs:896`](../../ui/dr-ui/src/library.rs#L896), [`ui/dr-ui/src/library.rs:980`](../../ui/dr-ui/src/library.rs#L980), [`ui/dr-ui/src/library_ui.rs:1339`](../../ui/dr-ui/src/library_ui.rs#L1339), [`ui/dr-ui/src/presets.rs:288`](../../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../../ui/dr-ui/src/presets.rs#L336), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:287`](../../ui/dr-ui/src/xmp_sync.rs#L287) | -| FR-PLAT-AND-2 | [`core/dr-catalog/src/walk.rs:1022`](../../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:435`](../../core/dr-catalog/src/walk.rs#L435), [`core/dr-sync-folder/src/lib.rs:242`](../../core/dr-sync-folder/src/lib.rs#L242), [`core/dr-sync-folder/src/tests.rs:51`](../../core/dr-sync-folder/src/tests.rs#L51), [`core/dr-sync/src/error.rs:113`](../../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/error.rs:195`](../../core/dr-sync/src/error.rs#L195), [`core/dr-sync/src/scan.rs:211`](../../core/dr-sync/src/scan.rs#L211), [`core/dr-sync/src/scan.rs:519`](../../core/dr-sync/src/scan.rs#L519), [`core/dr-sync/src/scan.rs:545`](../../core/dr-sync/src/scan.rs#L545), [`core/dr-sync/src/scan.rs:574`](../../core/dr-sync/src/scan.rs#L574), [`ui/dr-ui/src/library.rs:1355`](../../ui/dr-ui/src/library.rs#L1355), [`ui/dr-ui/src/library.rs:1408`](../../ui/dr-ui/src/library.rs#L1408), [`ui/dr-ui/src/library.rs:1439`](../../ui/dr-ui/src/library.rs#L1439), [`ui/dr-ui/src/library.rs:2019`](../../ui/dr-ui/src/library.rs#L2019), [`ui/dr-ui/src/library_ui.rs:1310`](../../ui/dr-ui/src/library_ui.rs#L1310), [`ui/dr-ui/src/library_ui.rs:1406`](../../ui/dr-ui/src/library_ui.rs#L1406), [`ui/dr-ui/src/library_ui.rs:2005`](../../ui/dr-ui/src/library_ui.rs#L2005), [`ui/dr-ui/src/library_ui.rs:328`](../../ui/dr-ui/src/library_ui.rs#L328), [`ui/dr-ui/src/library_ui.rs:573`](../../ui/dr-ui/src/library_ui.rs#L573) | +| FR-NC-8 | [`core/dr-catalog/src/rating.rs:204`](../../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:327`](../../core/dr-catalog/src/schema.rs#L327), [`core/dr-pipeline/src/sidecar.rs:137`](../../core/dr-pipeline/src/sidecar.rs#L137), [`core/dr-pipeline/src/sidecar.rs:2991`](../../core/dr-pipeline/src/sidecar.rs#L2991), [`core/dr-pipeline/src/sidecar.rs:660`](../../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:735`](../../core/dr-pipeline/src/sidecar.rs#L735), [`core/dr-pipeline/src/sidecar.rs:96`](../../core/dr-pipeline/src/sidecar.rs#L96), [`ui/dr-ui/src/lib.rs:2457`](../../ui/dr-ui/src/lib.rs#L2457), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11), [`ui/dr-ui/src/library/sidecar.rs:271`](../../ui/dr-ui/src/library/sidecar.rs#L271), [`ui/dr-ui/src/library/sidecar.rs:573`](../../ui/dr-ui/src/library/sidecar.rs#L573), [`ui/dr-ui/src/library/thumbnails_fetch.rs:504`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L504), [`ui/dr-ui/src/library_ui.rs:630`](../../ui/dr-ui/src/library_ui.rs#L630), [`ui/dr-ui/src/presets.rs:288`](../../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../../ui/dr-ui/src/presets.rs#L336) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:204`](../../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:1125`](../../core/dr-catalog/src/schema.rs#L1125), [`core/dr-catalog/src/schema.rs:327`](../../core/dr-catalog/src/schema.rs#L327), [`core/dr-catalog/src/schema.rs:740`](../../core/dr-catalog/src/schema.rs#L740), [`core/dr-catalog/src/sync.rs:1`](../../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:192`](../../core/dr-pipeline/src/sidecar.rs#L192), [`core/dr-pipeline/src/sidecar.rs:219`](../../core/dr-pipeline/src/sidecar.rs#L219), [`core/dr-pipeline/src/sidecar.rs:2520`](../../core/dr-pipeline/src/sidecar.rs#L2520), [`core/dr-pipeline/src/sidecar.rs:2757`](../../core/dr-pipeline/src/sidecar.rs#L2757), [`core/dr-pipeline/src/sidecar.rs:2991`](../../core/dr-pipeline/src/sidecar.rs#L2991), [`core/dr-pipeline/src/sidecar.rs:395`](../../core/dr-pipeline/src/sidecar.rs#L395), [`core/dr-pipeline/src/sidecar.rs:493`](../../core/dr-pipeline/src/sidecar.rs#L493), [`core/dr-pipeline/src/sidecar.rs:660`](../../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:710`](../../core/dr-pipeline/src/sidecar.rs#L710), [`core/dr-pipeline/src/sidecar.rs:735`](../../core/dr-pipeline/src/sidecar.rs#L735), [`core/dr-pipeline/src/spot.rs:245`](../../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/mask_sidecar.rs:1075`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1075), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:139`](../../core/dr-sync-nextcloud/src/lib.rs#L139), [`core/dr-sync/src/scan.rs:31`](../../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../../core/dr-sync/src/scan.rs#L926), [`core/dr-xmp/src/lib.rs:733`](../../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../../core/dr-xmp/src/lib.rs#L746), [`ui/dr-ui/src/derived_sync.rs:1070`](../../ui/dr-ui/src/derived_sync.rs#L1070), [`ui/dr-ui/src/derived_sync.rs:613`](../../ui/dr-ui/src/derived_sync.rs#L613), [`ui/dr-ui/src/derived_sync.rs:642`](../../ui/dr-ui/src/derived_sync.rs#L642), [`ui/dr-ui/src/derived_sync.rs:675`](../../ui/dr-ui/src/derived_sync.rs#L675), [`ui/dr-ui/src/derived_sync.rs:821`](../../ui/dr-ui/src/derived_sync.rs#L821), [`ui/dr-ui/src/library/scan.rs:226`](../../ui/dr-ui/src/library/scan.rs#L226), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:34`](../../ui/dr-ui/src/library/scan.rs#L34), [`ui/dr-ui/src/library/sidecar.rs:271`](../../ui/dr-ui/src/library/sidecar.rs#L271), [`ui/dr-ui/src/library/sidecar.rs:343`](../../ui/dr-ui/src/library/sidecar.rs#L343), [`ui/dr-ui/src/library/sidecar.rs:427`](../../ui/dr-ui/src/library/sidecar.rs#L427), [`ui/dr-ui/src/library/sidecar.rs:573`](../../ui/dr-ui/src/library/sidecar.rs#L573), [`ui/dr-ui/src/library/sidecar.rs:593`](../../ui/dr-ui/src/library/sidecar.rs#L593), [`ui/dr-ui/src/library/thumbnails_fetch.rs:504`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L504), [`ui/dr-ui/src/library_ui.rs:1339`](../../ui/dr-ui/src/library_ui.rs#L1339), [`ui/dr-ui/src/presets.rs:288`](../../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../../ui/dr-ui/src/presets.rs#L336), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:287`](../../ui/dr-ui/src/xmp_sync.rs#L287) | +| FR-PLAT-AND-2 | [`core/dr-catalog/src/walk.rs:1022`](../../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:435`](../../core/dr-catalog/src/walk.rs#L435), [`core/dr-sync-folder/src/lib.rs:242`](../../core/dr-sync-folder/src/lib.rs#L242), [`core/dr-sync-folder/src/tests.rs:51`](../../core/dr-sync-folder/src/tests.rs#L51), [`core/dr-sync/src/error.rs:113`](../../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/error.rs:195`](../../core/dr-sync/src/error.rs#L195), [`core/dr-sync/src/scan.rs:211`](../../core/dr-sync/src/scan.rs#L211), [`core/dr-sync/src/scan.rs:519`](../../core/dr-sync/src/scan.rs#L519), [`core/dr-sync/src/scan.rs:545`](../../core/dr-sync/src/scan.rs#L545), [`core/dr-sync/src/scan.rs:574`](../../core/dr-sync/src/scan.rs#L574), [`ui/dr-ui/src/library/scan.rs:126`](../../ui/dr-ui/src/library/scan.rs#L126), [`ui/dr-ui/src/library/scan.rs:179`](../../ui/dr-ui/src/library/scan.rs#L179), [`ui/dr-ui/src/library/scan.rs:210`](../../ui/dr-ui/src/library/scan.rs#L210), [`ui/dr-ui/src/library/scan.rs:605`](../../ui/dr-ui/src/library/scan.rs#L605), [`ui/dr-ui/src/library_ui.rs:1310`](../../ui/dr-ui/src/library_ui.rs#L1310), [`ui/dr-ui/src/library_ui.rs:1406`](../../ui/dr-ui/src/library_ui.rs#L1406), [`ui/dr-ui/src/library_ui.rs:2005`](../../ui/dr-ui/src/library_ui.rs#L2005), [`ui/dr-ui/src/library_ui.rs:328`](../../ui/dr-ui/src/library_ui.rs#L328), [`ui/dr-ui/src/library_ui.rs:573`](../../ui/dr-ui/src/library_ui.rs#L573) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/runner.rs:1`](../../core/dr-catalog/src/runner.rs#L1) | | FR-PLAT-AND-4 | [`core/dr-catalog/src/runner.rs:1`](../../core/dr-catalog/src/runner.rs#L1) | -| FR-PLAT-AND-5 | [`apps/darkroom-android/src/lib.rs:166`](../../apps/darkroom-android/src/lib.rs#L166), [`core/dr-gpu/src/adjust.rs:1057`](../../core/dr-gpu/src/adjust.rs#L1057), [`core/dr-gpu/src/detail.rs:161`](../../core/dr-gpu/src/detail.rs#L161), [`core/dr-gpu/src/detail.rs:547`](../../core/dr-gpu/src/detail.rs#L547), [`core/dr-gpu/tests/detail_stage.rs:417`](../../core/dr-gpu/tests/detail_stage.rs#L417), [`ui/dr-ui/src/develop.rs:4554`](../../ui/dr-ui/src/develop.rs#L4554), [`ui/dr-ui/src/identity_ui.rs:125`](../../ui/dr-ui/src/identity_ui.rs#L125), [`ui/dr-ui/src/lib.rs:1377`](../../ui/dr-ui/src/lib.rs#L1377), [`ui/dr-ui/src/lib.rs:1862`](../../ui/dr-ui/src/lib.rs#L1862), [`ui/dr-ui/src/memory.rs:163`](../../ui/dr-ui/src/memory.rs#L163), [`ui/dr-ui/src/memory.rs:1`](../../ui/dr-ui/src/memory.rs#L1) | +| FR-PLAT-AND-5 | [`apps/darkroom-android/src/lib.rs:166`](../../apps/darkroom-android/src/lib.rs#L166), [`core/dr-gpu/src/adjust.rs:1057`](../../core/dr-gpu/src/adjust.rs#L1057), [`core/dr-gpu/src/detail.rs:161`](../../core/dr-gpu/src/detail.rs#L161), [`core/dr-gpu/src/detail.rs:547`](../../core/dr-gpu/src/detail.rs#L547), [`core/dr-gpu/tests/detail_stage.rs:417`](../../core/dr-gpu/tests/detail_stage.rs#L417), [`ui/dr-ui/src/develop/render.rs:519`](../../ui/dr-ui/src/develop/render.rs#L519), [`ui/dr-ui/src/identity_ui.rs:125`](../../ui/dr-ui/src/identity_ui.rs#L125), [`ui/dr-ui/src/lib.rs:1377`](../../ui/dr-ui/src/lib.rs#L1377), [`ui/dr-ui/src/lib.rs:1862`](../../ui/dr-ui/src/lib.rs#L1862), [`ui/dr-ui/src/memory.rs:163`](../../ui/dr-ui/src/memory.rs#L163), [`ui/dr-ui/src/memory.rs:1`](../../ui/dr-ui/src/memory.rs#L1) | | FR-PLAT-AND-6 | [`apps/darkroom-android/src/lib.rs:441`](../../apps/darkroom-android/src/lib.rs#L441) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/dirs.rs:1`](../../platform/dr-plat/src/dirs.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:1166`](../../ui/dr-ui/src/lib.rs#L1166), [`ui/dr-ui/src/preset_store.rs:1`](../../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../../ui/dr-ui/src/settings_store.rs#L1) | | FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../../platform/dr-plat/src/display/x11.rs#L1) | @@ -144,12 +144,12 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-RAW-5 | [`core/dr-decode/src/lib.rs:183`](../../core/dr-decode/src/lib.rs#L183), [`core/dr-gpu/src/demosaic.rs:34`](../../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:734`](../../core/dr-gpu/src/demosaic.rs#L734), [`core/dr-gpu/src/demosaic.rs:813`](../../core/dr-gpu/src/demosaic.rs#L813), [`core/dr-gpu/src/demosaic.rs:937`](../../core/dr-gpu/src/demosaic.rs#L937) | | FR-UI-1 | [`core/dr-types/src/settings.rs:1377`](../../core/dr-types/src/settings.rs#L1377), [`core/dr-types/src/settings.rs:492`](../../core/dr-types/src/settings.rs#L492), [`core/dr-types/src/settings.rs:512`](../../core/dr-types/src/settings.rs#L512), [`ui/dr-ui/src/lib.rs:108`](../../ui/dr-ui/src/lib.rs#L108), [`ui/dr-ui/src/lib.rs:123`](../../ui/dr-ui/src/lib.rs#L123), [`ui/dr-ui/src/lib.rs:145`](../../ui/dr-ui/src/lib.rs#L145), [`ui/dr-ui/src/lib.rs:2711`](../../ui/dr-ui/src/lib.rs#L2711), [`ui/dr-ui/src/lib.rs:4043`](../../ui/dr-ui/src/lib.rs#L4043), [`ui/dr-ui/src/lib.rs:4062`](../../ui/dr-ui/src/lib.rs#L4062), [`ui/dr-ui/src/lib.rs:4089`](../../ui/dr-ui/src/lib.rs#L4089), [`ui/dr-ui/src/lib.rs:4140`](../../ui/dr-ui/src/lib.rs#L4140), [`ui/dr-ui/src/lib.rs:4173`](../../ui/dr-ui/src/lib.rs#L4173), [`ui/dr-ui/src/masks_ui.rs:1471`](../../ui/dr-ui/src/masks_ui.rs#L1471), [`ui/dr-ui/src/settings_ui.rs:179`](../../ui/dr-ui/src/settings_ui.rs#L179), [`ui/dr-ui/ui/adjust.slint:974`](../../ui/dr-ui/ui/adjust.slint#L974), [`ui/dr-ui/ui/app.slint:1010`](../../ui/dr-ui/ui/app.slint#L1010), [`ui/dr-ui/ui/app.slint:2842`](../../ui/dr-ui/ui/app.slint#L2842), [`ui/dr-ui/ui/app.slint:906`](../../ui/dr-ui/ui/app.slint#L906), [`ui/dr-ui/ui/identity.slint:246`](../../ui/dr-ui/ui/identity.slint#L246), [`ui/dr-ui/ui/library.slint:1076`](../../ui/dr-ui/ui/library.slint#L1076), [`ui/dr-ui/ui/settings.slint:122`](../../ui/dr-ui/ui/settings.slint#L122), [`ui/dr-ui/ui/settings.slint:681`](../../ui/dr-ui/ui/settings.slint#L681), [`ui/dr-ui/ui/toolrail.slint:156`](../../ui/dr-ui/ui/toolrail.slint#L156), [`ui/dr-ui/ui/toolrail.slint:89`](../../ui/dr-ui/ui/toolrail.slint#L89) | | FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:116`](../../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:1197`](../../ui/dr-ui/src/collections_ui.rs#L1197), [`ui/dr-ui/src/collections_ui.rs:1207`](../../ui/dr-ui/src/collections_ui.rs#L1207), [`ui/dr-ui/src/collections_ui.rs:154`](../../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1704`](../../ui/dr-ui/src/collections_ui.rs#L1704), [`ui/dr-ui/src/collections_ui.rs:1721`](../../ui/dr-ui/src/collections_ui.rs#L1721), [`ui/dr-ui/src/collections_ui.rs:1973`](../../ui/dr-ui/src/collections_ui.rs#L1973), [`ui/dr-ui/src/collections_ui.rs:2701`](../../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:3596`](../../ui/dr-ui/src/collections_ui.rs#L3596), [`ui/dr-ui/src/gestures.rs:1`](../../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:108`](../../ui/dr-ui/src/lib.rs#L108), [`ui/dr-ui/src/library_ui.rs:6766`](../../ui/dr-ui/src/library_ui.rs#L6766), [`ui/dr-ui/src/library_ui.rs:8257`](../../ui/dr-ui/src/library_ui.rs#L8257), [`ui/dr-ui/ui/app.slint:594`](../../ui/dr-ui/ui/app.slint#L594), [`ui/dr-ui/ui/app.slint:608`](../../ui/dr-ui/ui/app.slint#L608), [`ui/dr-ui/ui/app.slint:71`](../../ui/dr-ui/ui/app.slint#L71), [`ui/dr-ui/ui/app.slint:994`](../../ui/dr-ui/ui/app.slint#L994), [`ui/dr-ui/ui/controls.slint:626`](../../ui/dr-ui/ui/controls.slint#L626), [`ui/dr-ui/ui/gestures.slint:1`](../../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1287`](../../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1391`](../../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2621`](../../ui/dr-ui/ui/library.slint#L2621), [`ui/dr-ui/ui/library.slint:4370`](../../ui/dr-ui/ui/library.slint#L4370), [`ui/dr-ui/ui/library.slint:965`](../../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:998`](../../ui/dr-ui/ui/library.slint#L998), [`ui/dr-ui/ui/presets.slint:19`](../../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/settings.slint:136`](../../ui/dr-ui/ui/settings.slint#L136) | -| FR-UI-3 | [`ui/dr-ui/src/collections_ui.rs:209`](../../ui/dr-ui/src/collections_ui.rs#L209), [`ui/dr-ui/src/collections_ui.rs:2701`](../../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:414`](../../ui/dr-ui/src/collections_ui.rs#L414), [`ui/dr-ui/src/collections_ui.rs:427`](../../ui/dr-ui/src/collections_ui.rs#L427), [`ui/dr-ui/src/develop.rs:2859`](../../ui/dr-ui/src/develop.rs#L2859), [`ui/dr-ui/src/develop.rs:3247`](../../ui/dr-ui/src/develop.rs#L3247), [`ui/dr-ui/src/library_ui.rs:5778`](../../ui/dr-ui/src/library_ui.rs#L5778), [`ui/dr-ui/src/masks_ui.rs:1563`](../../ui/dr-ui/src/masks_ui.rs#L1563), [`ui/dr-ui/src/masks_ui.rs:1585`](../../ui/dr-ui/src/masks_ui.rs#L1585), [`ui/dr-ui/src/masks_ui.rs:339`](../../ui/dr-ui/src/masks_ui.rs#L339), [`ui/dr-ui/src/spots_ui.rs:19`](../../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2385`](../../ui/dr-ui/ui/app.slint#L2385), [`ui/dr-ui/ui/collections.slint:1127`](../../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:75`](../../ui/dr-ui/ui/collections.slint#L75), [`ui/dr-ui/ui/collections.slint:877`](../../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/masks.slint:1216`](../../ui/dr-ui/ui/masks.slint#L1216), [`ui/dr-ui/ui/widgets.slint:71`](../../ui/dr-ui/ui/widgets.slint#L71) | -| FR-UI-4 | [`tools/traceability/src/gestures.rs:1`](../../tools/traceability/src/gestures.rs#L1), [`ui/dr-ui/src/collections_ui.rs:116`](../../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:117`](../../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:1197`](../../ui/dr-ui/src/collections_ui.rs#L1197), [`ui/dr-ui/src/collections_ui.rs:1207`](../../ui/dr-ui/src/collections_ui.rs#L1207), [`ui/dr-ui/src/collections_ui.rs:126`](../../ui/dr-ui/src/collections_ui.rs#L126), [`ui/dr-ui/src/collections_ui.rs:154`](../../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1704`](../../ui/dr-ui/src/collections_ui.rs#L1704), [`ui/dr-ui/src/collections_ui.rs:1721`](../../ui/dr-ui/src/collections_ui.rs#L1721), [`ui/dr-ui/src/collections_ui.rs:1837`](../../ui/dr-ui/src/collections_ui.rs#L1837), [`ui/dr-ui/src/collections_ui.rs:1973`](../../ui/dr-ui/src/collections_ui.rs#L1973), [`ui/dr-ui/src/collections_ui.rs:2000`](../../ui/dr-ui/src/collections_ui.rs#L2000), [`ui/dr-ui/src/collections_ui.rs:209`](../../ui/dr-ui/src/collections_ui.rs#L209), [`ui/dr-ui/src/collections_ui.rs:2116`](../../ui/dr-ui/src/collections_ui.rs#L2116), [`ui/dr-ui/src/collections_ui.rs:2701`](../../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:2755`](../../ui/dr-ui/src/collections_ui.rs#L2755), [`ui/dr-ui/src/collections_ui.rs:3208`](../../ui/dr-ui/src/collections_ui.rs#L3208), [`ui/dr-ui/src/collections_ui.rs:3596`](../../ui/dr-ui/src/collections_ui.rs#L3596), [`ui/dr-ui/src/collections_ui.rs:3633`](../../ui/dr-ui/src/collections_ui.rs#L3633), [`ui/dr-ui/src/collections_ui.rs:414`](../../ui/dr-ui/src/collections_ui.rs#L414), [`ui/dr-ui/src/collections_ui.rs:427`](../../ui/dr-ui/src/collections_ui.rs#L427), [`ui/dr-ui/src/collections_ui.rs:514`](../../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:718`](../../ui/dr-ui/src/collections_ui.rs#L718), [`ui/dr-ui/src/collections_ui.rs:738`](../../ui/dr-ui/src/collections_ui.rs#L738), [`ui/dr-ui/src/develop.rs:5325`](../../ui/dr-ui/src/develop.rs#L5325), [`ui/dr-ui/src/develop.rs:5350`](../../ui/dr-ui/src/develop.rs#L5350), [`ui/dr-ui/src/develop.rs:5361`](../../ui/dr-ui/src/develop.rs#L5361), [`ui/dr-ui/src/develop.rs:5375`](../../ui/dr-ui/src/develop.rs#L5375), [`ui/dr-ui/src/develop.rs:6603`](../../ui/dr-ui/src/develop.rs#L6603), [`ui/dr-ui/src/develop.rs:6634`](../../ui/dr-ui/src/develop.rs#L6634), [`ui/dr-ui/src/develop.rs:6655`](../../ui/dr-ui/src/develop.rs#L6655), [`ui/dr-ui/src/gesture_book.rs:3`](../../ui/dr-ui/src/gesture_book.rs#L3), [`ui/dr-ui/src/gestures.rs:1`](../../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:1350`](../../ui/dr-ui/src/lib.rs#L1350), [`ui/dr-ui/src/lib.rs:1944`](../../ui/dr-ui/src/lib.rs#L1944), [`ui/dr-ui/src/lib.rs:2368`](../../ui/dr-ui/src/lib.rs#L2368), [`ui/dr-ui/src/lib.rs:2558`](../../ui/dr-ui/src/lib.rs#L2558), [`ui/dr-ui/src/lib.rs:2618`](../../ui/dr-ui/src/lib.rs#L2618), [`ui/dr-ui/src/lib.rs:3284`](../../ui/dr-ui/src/lib.rs#L3284), [`ui/dr-ui/src/lib.rs:3853`](../../ui/dr-ui/src/lib.rs#L3853), [`ui/dr-ui/src/lib.rs:4277`](../../ui/dr-ui/src/lib.rs#L4277), [`ui/dr-ui/src/lib.rs:460`](../../ui/dr-ui/src/lib.rs#L460), [`ui/dr-ui/src/lib.rs:489`](../../ui/dr-ui/src/lib.rs#L489), [`ui/dr-ui/src/library.rs:2789`](../../ui/dr-ui/src/library.rs#L2789), [`ui/dr-ui/src/library.rs:2804`](../../ui/dr-ui/src/library.rs#L2804), [`ui/dr-ui/src/library_ui.rs:387`](../../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:527`](../../ui/dr-ui/src/library_ui.rs#L527), [`ui/dr-ui/src/library_ui.rs:5778`](../../ui/dr-ui/src/library_ui.rs#L5778), [`ui/dr-ui/src/library_ui.rs:5790`](../../ui/dr-ui/src/library_ui.rs#L5790), [`ui/dr-ui/src/library_ui.rs:5860`](../../ui/dr-ui/src/library_ui.rs#L5860), [`ui/dr-ui/src/library_ui.rs:5970`](../../ui/dr-ui/src/library_ui.rs#L5970), [`ui/dr-ui/src/library_ui.rs:5998`](../../ui/dr-ui/src/library_ui.rs#L5998), [`ui/dr-ui/src/library_ui.rs:604`](../../ui/dr-ui/src/library_ui.rs#L604), [`ui/dr-ui/src/library_ui.rs:6899`](../../ui/dr-ui/src/library_ui.rs#L6899), [`ui/dr-ui/src/settings_ui.rs:157`](../../ui/dr-ui/src/settings_ui.rs#L157), [`ui/dr-ui/src/settings_ui.rs:524`](../../ui/dr-ui/src/settings_ui.rs#L524), [`ui/dr-ui/ui/app.slint:171`](../../ui/dr-ui/ui/app.slint#L171), [`ui/dr-ui/ui/app.slint:2080`](../../ui/dr-ui/ui/app.slint#L2080), [`ui/dr-ui/ui/app.slint:2202`](../../ui/dr-ui/ui/app.slint#L2202), [`ui/dr-ui/ui/app.slint:2609`](../../ui/dr-ui/ui/app.slint#L2609), [`ui/dr-ui/ui/app.slint:2742`](../../ui/dr-ui/ui/app.slint#L2742), [`ui/dr-ui/ui/app.slint:3213`](../../ui/dr-ui/ui/app.slint#L3213), [`ui/dr-ui/ui/app.slint:570`](../../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/app.slint:599`](../../ui/dr-ui/ui/app.slint#L599), [`ui/dr-ui/ui/app.slint:608`](../../ui/dr-ui/ui/app.slint#L608), [`ui/dr-ui/ui/app.slint:712`](../../ui/dr-ui/ui/app.slint#L712), [`ui/dr-ui/ui/collections.slint:75`](../../ui/dr-ui/ui/collections.slint#L75), [`ui/dr-ui/ui/gestures.slint:1`](../../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1053`](../../ui/dr-ui/ui/library.slint#L1053), [`ui/dr-ui/ui/library.slint:1262`](../../ui/dr-ui/ui/library.slint#L1262), [`ui/dr-ui/ui/library.slint:1287`](../../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1327`](../../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:1384`](../../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1391`](../../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2621`](../../ui/dr-ui/ui/library.slint#L2621), [`ui/dr-ui/ui/library.slint:4224`](../../ui/dr-ui/ui/library.slint#L4224), [`ui/dr-ui/ui/library.slint:4311`](../../ui/dr-ui/ui/library.slint#L4311), [`ui/dr-ui/ui/library.slint:4370`](../../ui/dr-ui/ui/library.slint#L4370), [`ui/dr-ui/ui/library.slint:965`](../../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:992`](../../ui/dr-ui/ui/library.slint#L992), [`ui/dr-ui/ui/library.slint:998`](../../ui/dr-ui/ui/library.slint#L998), [`ui/dr-ui/ui/settings.slint:114`](../../ui/dr-ui/ui/settings.slint#L114), [`ui/dr-ui/ui/settings.slint:656`](../../ui/dr-ui/ui/settings.slint#L656) | +| FR-UI-3 | [`ui/dr-ui/src/collections_ui.rs:209`](../../ui/dr-ui/src/collections_ui.rs#L209), [`ui/dr-ui/src/collections_ui.rs:2701`](../../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:414`](../../ui/dr-ui/src/collections_ui.rs#L414), [`ui/dr-ui/src/collections_ui.rs:427`](../../ui/dr-ui/src/collections_ui.rs#L427), [`ui/dr-ui/src/develop/masks.rs:360`](../../ui/dr-ui/src/develop/masks.rs#L360), [`ui/dr-ui/src/develop/repairs.rs:49`](../../ui/dr-ui/src/develop/repairs.rs#L49), [`ui/dr-ui/src/library_ui.rs:5778`](../../ui/dr-ui/src/library_ui.rs#L5778), [`ui/dr-ui/src/masks_ui.rs:1563`](../../ui/dr-ui/src/masks_ui.rs#L1563), [`ui/dr-ui/src/masks_ui.rs:1585`](../../ui/dr-ui/src/masks_ui.rs#L1585), [`ui/dr-ui/src/masks_ui.rs:339`](../../ui/dr-ui/src/masks_ui.rs#L339), [`ui/dr-ui/src/spots_ui.rs:19`](../../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2385`](../../ui/dr-ui/ui/app.slint#L2385), [`ui/dr-ui/ui/collections.slint:1127`](../../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:75`](../../ui/dr-ui/ui/collections.slint#L75), [`ui/dr-ui/ui/collections.slint:877`](../../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/masks.slint:1216`](../../ui/dr-ui/ui/masks.slint#L1216), [`ui/dr-ui/ui/widgets.slint:71`](../../ui/dr-ui/ui/widgets.slint#L71) | +| FR-UI-4 | [`tools/traceability/src/gestures.rs:1`](../../tools/traceability/src/gestures.rs#L1), [`ui/dr-ui/src/collections_ui.rs:116`](../../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:117`](../../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:1197`](../../ui/dr-ui/src/collections_ui.rs#L1197), [`ui/dr-ui/src/collections_ui.rs:1207`](../../ui/dr-ui/src/collections_ui.rs#L1207), [`ui/dr-ui/src/collections_ui.rs:126`](../../ui/dr-ui/src/collections_ui.rs#L126), [`ui/dr-ui/src/collections_ui.rs:154`](../../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1704`](../../ui/dr-ui/src/collections_ui.rs#L1704), [`ui/dr-ui/src/collections_ui.rs:1721`](../../ui/dr-ui/src/collections_ui.rs#L1721), [`ui/dr-ui/src/collections_ui.rs:1837`](../../ui/dr-ui/src/collections_ui.rs#L1837), [`ui/dr-ui/src/collections_ui.rs:1973`](../../ui/dr-ui/src/collections_ui.rs#L1973), [`ui/dr-ui/src/collections_ui.rs:2000`](../../ui/dr-ui/src/collections_ui.rs#L2000), [`ui/dr-ui/src/collections_ui.rs:209`](../../ui/dr-ui/src/collections_ui.rs#L209), [`ui/dr-ui/src/collections_ui.rs:2116`](../../ui/dr-ui/src/collections_ui.rs#L2116), [`ui/dr-ui/src/collections_ui.rs:2701`](../../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:2755`](../../ui/dr-ui/src/collections_ui.rs#L2755), [`ui/dr-ui/src/collections_ui.rs:3208`](../../ui/dr-ui/src/collections_ui.rs#L3208), [`ui/dr-ui/src/collections_ui.rs:3596`](../../ui/dr-ui/src/collections_ui.rs#L3596), [`ui/dr-ui/src/collections_ui.rs:3633`](../../ui/dr-ui/src/collections_ui.rs#L3633), [`ui/dr-ui/src/collections_ui.rs:414`](../../ui/dr-ui/src/collections_ui.rs#L414), [`ui/dr-ui/src/collections_ui.rs:427`](../../ui/dr-ui/src/collections_ui.rs#L427), [`ui/dr-ui/src/collections_ui.rs:514`](../../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:718`](../../ui/dr-ui/src/collections_ui.rs#L718), [`ui/dr-ui/src/collections_ui.rs:738`](../../ui/dr-ui/src/collections_ui.rs#L738), [`ui/dr-ui/src/develop/framing.rs:444`](../../ui/dr-ui/src/develop/framing.rs#L444), [`ui/dr-ui/src/develop/framing.rs:469`](../../ui/dr-ui/src/develop/framing.rs#L469), [`ui/dr-ui/src/develop/framing.rs:480`](../../ui/dr-ui/src/develop/framing.rs#L480), [`ui/dr-ui/src/develop/framing.rs:494`](../../ui/dr-ui/src/develop/framing.rs#L494), [`ui/dr-ui/src/develop/framing.rs:668`](../../ui/dr-ui/src/develop/framing.rs#L668), [`ui/dr-ui/src/develop/framing.rs:699`](../../ui/dr-ui/src/develop/framing.rs#L699), [`ui/dr-ui/src/develop/framing.rs:720`](../../ui/dr-ui/src/develop/framing.rs#L720), [`ui/dr-ui/src/gesture_book.rs:3`](../../ui/dr-ui/src/gesture_book.rs#L3), [`ui/dr-ui/src/gestures.rs:1`](../../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:1350`](../../ui/dr-ui/src/lib.rs#L1350), [`ui/dr-ui/src/lib.rs:1944`](../../ui/dr-ui/src/lib.rs#L1944), [`ui/dr-ui/src/lib.rs:2368`](../../ui/dr-ui/src/lib.rs#L2368), [`ui/dr-ui/src/lib.rs:2558`](../../ui/dr-ui/src/lib.rs#L2558), [`ui/dr-ui/src/lib.rs:2618`](../../ui/dr-ui/src/lib.rs#L2618), [`ui/dr-ui/src/lib.rs:3284`](../../ui/dr-ui/src/lib.rs#L3284), [`ui/dr-ui/src/lib.rs:3853`](../../ui/dr-ui/src/lib.rs#L3853), [`ui/dr-ui/src/lib.rs:4277`](../../ui/dr-ui/src/lib.rs#L4277), [`ui/dr-ui/src/lib.rs:460`](../../ui/dr-ui/src/lib.rs#L460), [`ui/dr-ui/src/lib.rs:489`](../../ui/dr-ui/src/lib.rs#L489), [`ui/dr-ui/src/library/thumbnails_fetch.rs:711`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L711), [`ui/dr-ui/src/library/thumbnails_fetch.rs:726`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L726), [`ui/dr-ui/src/library_ui.rs:387`](../../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:527`](../../ui/dr-ui/src/library_ui.rs#L527), [`ui/dr-ui/src/library_ui.rs:5778`](../../ui/dr-ui/src/library_ui.rs#L5778), [`ui/dr-ui/src/library_ui.rs:5790`](../../ui/dr-ui/src/library_ui.rs#L5790), [`ui/dr-ui/src/library_ui.rs:5860`](../../ui/dr-ui/src/library_ui.rs#L5860), [`ui/dr-ui/src/library_ui.rs:5970`](../../ui/dr-ui/src/library_ui.rs#L5970), [`ui/dr-ui/src/library_ui.rs:5998`](../../ui/dr-ui/src/library_ui.rs#L5998), [`ui/dr-ui/src/library_ui.rs:604`](../../ui/dr-ui/src/library_ui.rs#L604), [`ui/dr-ui/src/library_ui.rs:6899`](../../ui/dr-ui/src/library_ui.rs#L6899), [`ui/dr-ui/src/settings_ui.rs:157`](../../ui/dr-ui/src/settings_ui.rs#L157), [`ui/dr-ui/src/settings_ui.rs:524`](../../ui/dr-ui/src/settings_ui.rs#L524), [`ui/dr-ui/ui/app.slint:171`](../../ui/dr-ui/ui/app.slint#L171), [`ui/dr-ui/ui/app.slint:2080`](../../ui/dr-ui/ui/app.slint#L2080), [`ui/dr-ui/ui/app.slint:2202`](../../ui/dr-ui/ui/app.slint#L2202), [`ui/dr-ui/ui/app.slint:2609`](../../ui/dr-ui/ui/app.slint#L2609), [`ui/dr-ui/ui/app.slint:2742`](../../ui/dr-ui/ui/app.slint#L2742), [`ui/dr-ui/ui/app.slint:3213`](../../ui/dr-ui/ui/app.slint#L3213), [`ui/dr-ui/ui/app.slint:570`](../../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/app.slint:599`](../../ui/dr-ui/ui/app.slint#L599), [`ui/dr-ui/ui/app.slint:608`](../../ui/dr-ui/ui/app.slint#L608), [`ui/dr-ui/ui/app.slint:712`](../../ui/dr-ui/ui/app.slint#L712), [`ui/dr-ui/ui/collections.slint:75`](../../ui/dr-ui/ui/collections.slint#L75), [`ui/dr-ui/ui/gestures.slint:1`](../../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1053`](../../ui/dr-ui/ui/library.slint#L1053), [`ui/dr-ui/ui/library.slint:1262`](../../ui/dr-ui/ui/library.slint#L1262), [`ui/dr-ui/ui/library.slint:1287`](../../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1327`](../../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:1384`](../../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1391`](../../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2621`](../../ui/dr-ui/ui/library.slint#L2621), [`ui/dr-ui/ui/library.slint:4224`](../../ui/dr-ui/ui/library.slint#L4224), [`ui/dr-ui/ui/library.slint:4311`](../../ui/dr-ui/ui/library.slint#L4311), [`ui/dr-ui/ui/library.slint:4370`](../../ui/dr-ui/ui/library.slint#L4370), [`ui/dr-ui/ui/library.slint:965`](../../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:992`](../../ui/dr-ui/ui/library.slint#L992), [`ui/dr-ui/ui/library.slint:998`](../../ui/dr-ui/ui/library.slint#L998), [`ui/dr-ui/ui/settings.slint:114`](../../ui/dr-ui/ui/settings.slint#L114), [`ui/dr-ui/ui/settings.slint:656`](../../ui/dr-ui/ui/settings.slint#L656) | | FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:3329`](../../ui/dr-ui/src/lib.rs#L3329), [`ui/dr-ui/src/lib.rs:4237`](../../ui/dr-ui/src/lib.rs#L4237), [`ui/dr-ui/src/lib.rs:4428`](../../ui/dr-ui/src/lib.rs#L4428), [`ui/dr-ui/src/masks_ui.rs:1518`](../../ui/dr-ui/src/masks_ui.rs#L1518), [`ui/dr-ui/ui/app.slint:2569`](../../ui/dr-ui/ui/app.slint#L2569), [`ui/dr-ui/ui/app.slint:2609`](../../ui/dr-ui/ui/app.slint#L2609), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/session.slint:75`](../../ui/dr-ui/ui/session.slint#L75) | | FR-UI-6 | [`ui/dr-ui/ui/widgets.slint:1`](../../ui/dr-ui/ui/widgets.slint#L1) | -| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:195`](../../core/dr-pipeline/src/descriptor.rs#L195), [`core/dr-pipeline/src/framing.rs:395`](../../core/dr-pipeline/src/framing.rs#L395), [`core/dr-types/src/settings.rs:492`](../../core/dr-types/src/settings.rs#L492), [`ui/dr-ui/src/develop.rs:1330`](../../ui/dr-ui/src/develop.rs#L1330), [`ui/dr-ui/src/lib.rs:4062`](../../ui/dr-ui/src/lib.rs#L4062), [`ui/dr-ui/ui/adjust.slint:974`](../../ui/dr-ui/ui/adjust.slint#L974), [`ui/dr-ui/ui/app.slint:2325`](../../ui/dr-ui/ui/app.slint#L2325), [`ui/dr-ui/ui/settings.slint:681`](../../ui/dr-ui/ui/settings.slint#L681), [`ui/dr-ui/ui/toolrail.slint:89`](../../ui/dr-ui/ui/toolrail.slint#L89) | -| FR-UI-8 | [`core/dr-catalog/src/collections.rs:654`](../../core/dr-catalog/src/collections.rs#L654), [`core/dr-catalog/src/collections.rs:675`](../../core/dr-catalog/src/collections.rs#L675), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/derived_sync.rs:216`](../../ui/dr-ui/src/derived_sync.rs#L216), [`ui/dr-ui/src/derived_sync.rs:81`](../../ui/dr-ui/src/derived_sync.rs#L81), [`ui/dr-ui/src/derived_sync.rs:894`](../../ui/dr-ui/src/derived_sync.rs#L894), [`ui/dr-ui/src/derived_sync.rs:904`](../../ui/dr-ui/src/derived_sync.rs#L904), [`ui/dr-ui/src/derived_sync.rs:986`](../../ui/dr-ui/src/derived_sync.rs#L986), [`ui/dr-ui/src/library.rs:1187`](../../ui/dr-ui/src/library.rs#L1187), [`ui/dr-ui/src/library.rs:4708`](../../ui/dr-ui/src/library.rs#L4708), [`ui/dr-ui/src/library.rs:5476`](../../ui/dr-ui/src/library.rs#L5476), [`ui/dr-ui/src/library.rs:5542`](../../ui/dr-ui/src/library.rs#L5542), [`ui/dr-ui/src/library_ui.rs:1007`](../../ui/dr-ui/src/library_ui.rs#L1007), [`ui/dr-ui/src/library_ui.rs:195`](../../ui/dr-ui/src/library_ui.rs#L195), [`ui/dr-ui/src/library_ui.rs:2192`](../../ui/dr-ui/src/library_ui.rs#L2192), [`ui/dr-ui/src/library_ui.rs:2381`](../../ui/dr-ui/src/library_ui.rs#L2381), [`ui/dr-ui/src/library_ui.rs:2398`](../../ui/dr-ui/src/library_ui.rs#L2398), [`ui/dr-ui/src/library_ui.rs:2496`](../../ui/dr-ui/src/library_ui.rs#L2496), [`ui/dr-ui/src/library_ui.rs:5811`](../../ui/dr-ui/src/library_ui.rs#L5811), [`ui/dr-ui/src/library_ui.rs:5851`](../../ui/dr-ui/src/library_ui.rs#L5851), [`ui/dr-ui/src/library_ui.rs:5880`](../../ui/dr-ui/src/library_ui.rs#L5880), [`ui/dr-ui/src/library_ui.rs:6143`](../../ui/dr-ui/src/library_ui.rs#L6143), [`ui/dr-ui/src/library_ui.rs:6366`](../../ui/dr-ui/src/library_ui.rs#L6366), [`ui/dr-ui/src/library_ui.rs:7120`](../../ui/dr-ui/src/library_ui.rs#L7120), [`ui/dr-ui/src/library_ui.rs:935`](../../ui/dr-ui/src/library_ui.rs#L935), [`ui/dr-ui/src/library_ui.rs:968`](../../ui/dr-ui/src/library_ui.rs#L968), [`ui/dr-ui/src/place.rs:1`](../../ui/dr-ui/src/place.rs#L1) | +| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:195`](../../core/dr-pipeline/src/descriptor.rs#L195), [`core/dr-pipeline/src/framing.rs:395`](../../core/dr-pipeline/src/framing.rs#L395), [`core/dr-types/src/settings.rs:492`](../../core/dr-types/src/settings.rs#L492), [`ui/dr-ui/src/develop/rows.rs:118`](../../ui/dr-ui/src/develop/rows.rs#L118), [`ui/dr-ui/src/lib.rs:4062`](../../ui/dr-ui/src/lib.rs#L4062), [`ui/dr-ui/ui/adjust.slint:974`](../../ui/dr-ui/ui/adjust.slint#L974), [`ui/dr-ui/ui/app.slint:2325`](../../ui/dr-ui/ui/app.slint#L2325), [`ui/dr-ui/ui/settings.slint:681`](../../ui/dr-ui/ui/settings.slint#L681), [`ui/dr-ui/ui/toolrail.slint:89`](../../ui/dr-ui/ui/toolrail.slint#L89) | +| FR-UI-8 | [`core/dr-catalog/src/collections.rs:654`](../../core/dr-catalog/src/collections.rs#L654), [`core/dr-catalog/src/collections.rs:675`](../../core/dr-catalog/src/collections.rs#L675), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/derived_sync.rs:216`](../../ui/dr-ui/src/derived_sync.rs#L216), [`ui/dr-ui/src/derived_sync.rs:81`](../../ui/dr-ui/src/derived_sync.rs#L81), [`ui/dr-ui/src/derived_sync.rs:894`](../../ui/dr-ui/src/derived_sync.rs#L894), [`ui/dr-ui/src/derived_sync.rs:904`](../../ui/dr-ui/src/derived_sync.rs#L904), [`ui/dr-ui/src/derived_sync.rs:986`](../../ui/dr-ui/src/derived_sync.rs#L986), [`ui/dr-ui/src/library/cells.rs:274`](../../ui/dr-ui/src/library/cells.rs#L274), [`ui/dr-ui/src/library/cells.rs:553`](../../ui/dr-ui/src/library/cells.rs#L553), [`ui/dr-ui/src/library/cells.rs:619`](../../ui/dr-ui/src/library/cells.rs#L619), [`ui/dr-ui/src/library/paths.rs:22`](../../ui/dr-ui/src/library/paths.rs#L22), [`ui/dr-ui/src/library_ui.rs:1007`](../../ui/dr-ui/src/library_ui.rs#L1007), [`ui/dr-ui/src/library_ui.rs:195`](../../ui/dr-ui/src/library_ui.rs#L195), [`ui/dr-ui/src/library_ui.rs:2192`](../../ui/dr-ui/src/library_ui.rs#L2192), [`ui/dr-ui/src/library_ui.rs:2381`](../../ui/dr-ui/src/library_ui.rs#L2381), [`ui/dr-ui/src/library_ui.rs:2398`](../../ui/dr-ui/src/library_ui.rs#L2398), [`ui/dr-ui/src/library_ui.rs:2496`](../../ui/dr-ui/src/library_ui.rs#L2496), [`ui/dr-ui/src/library_ui.rs:5811`](../../ui/dr-ui/src/library_ui.rs#L5811), [`ui/dr-ui/src/library_ui.rs:5851`](../../ui/dr-ui/src/library_ui.rs#L5851), [`ui/dr-ui/src/library_ui.rs:5880`](../../ui/dr-ui/src/library_ui.rs#L5880), [`ui/dr-ui/src/library_ui.rs:6143`](../../ui/dr-ui/src/library_ui.rs#L6143), [`ui/dr-ui/src/library_ui.rs:6366`](../../ui/dr-ui/src/library_ui.rs#L6366), [`ui/dr-ui/src/library_ui.rs:7120`](../../ui/dr-ui/src/library_ui.rs#L7120), [`ui/dr-ui/src/library_ui.rs:935`](../../ui/dr-ui/src/library_ui.rs#L935), [`ui/dr-ui/src/library_ui.rs:968`](../../ui/dr-ui/src/library_ui.rs#L968), [`ui/dr-ui/src/place.rs:1`](../../ui/dr-ui/src/place.rs#L1) | | NFR-A11Y-2 | [`ui/dr-ui/tests/ui_controls_are_accessible.rs:1`](../../ui/dr-ui/tests/ui_controls_are_accessible.rs#L1), [`ui/dr-ui/ui/adjust.slint:171`](../../ui/dr-ui/ui/adjust.slint#L171), [`ui/dr-ui/ui/app.slint:2742`](../../ui/dr-ui/ui/app.slint#L2742), [`ui/dr-ui/ui/app.slint:2780`](../../ui/dr-ui/ui/app.slint#L2780) | | NFR-A11Y-3 | [`ui/dr-ui/src/histogram.rs:356`](../../ui/dr-ui/src/histogram.rs#L356), [`ui/dr-ui/ui/histogram.slint:137`](../../ui/dr-ui/ui/histogram.slint#L137), [`ui/dr-ui/ui/library.slint:761`](../../ui/dr-ui/ui/library.slint#L761), [`ui/dr-ui/ui/library.slint:909`](../../ui/dr-ui/ui/library.slint#L909), [`ui/dr-ui/ui/peaking.slint:1`](../../ui/dr-ui/ui/peaking.slint#L1) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1) | @@ -161,23 +161,23 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | NFR-OPS-3 | [`core/dr-types/src/settings.rs:1`](../../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../../ui/dr-ui/src/settings_store.rs#L1) | | NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`tools/bench/src/catalog_open.rs:1`](../../tools/bench/src/catalog_open.rs#L1) | | NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../../core/dr-decode/src/preview.rs#L121) | -| NFR-P14 | [`core/dr-gpu/src/focus.rs:186`](../../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop.rs:4238`](../../ui/dr-ui/src/develop.rs#L4238), [`ui/dr-ui/src/lib.rs:2153`](../../ui/dr-ui/src/lib.rs#L2153) | +| NFR-P14 | [`core/dr-gpu/src/focus.rs:186`](../../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop/render.rs:348`](../../ui/dr-ui/src/develop/render.rs#L348), [`ui/dr-ui/src/lib.rs:2153`](../../ui/dr-ui/src/lib.rs#L2153) | | NFR-P3 | [`tools/bench/src/thumbnails.rs:1`](../../tools/bench/src/thumbnails.rs#L1) | -| NFR-P5 | [`core/dr-catalog/src/schema.rs:564`](../../core/dr-catalog/src/schema.rs#L564), [`ui/dr-ui/src/library.rs:4815`](../../ui/dr-ui/src/library.rs#L4815), [`ui/dr-ui/src/library.rs:5978`](../../ui/dr-ui/src/library.rs#L5978), [`ui/dr-ui/src/library_ui.rs:5005`](../../ui/dr-ui/src/library_ui.rs#L5005), [`ui/dr-ui/src/library_ui.rs:64`](../../ui/dr-ui/src/library_ui.rs#L64) | -| NFR-P9 | [`core/dr-catalog/src/schema.rs:917`](../../core/dr-catalog/src/schema.rs#L917), [`core/dr-face/src/references.rs:1`](../../core/dr-face/src/references.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1`](../../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:1005`](../../ui/dr-ui/src/export.rs#L1005), [`ui/dr-ui/src/library.rs:1`](../../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../../ui/dr-ui/src/trash.rs#L1) | +| NFR-P5 | [`core/dr-catalog/src/schema.rs:564`](../../core/dr-catalog/src/schema.rs#L564), [`ui/dr-ui/src/library/cells.rs:382`](../../ui/dr-ui/src/library/cells.rs#L382), [`ui/dr-ui/src/library/cells.rs:708`](../../ui/dr-ui/src/library/cells.rs#L708), [`ui/dr-ui/src/library_ui.rs:5005`](../../ui/dr-ui/src/library_ui.rs#L5005), [`ui/dr-ui/src/library_ui.rs:64`](../../ui/dr-ui/src/library_ui.rs#L64) | +| NFR-P9 | [`core/dr-catalog/src/schema.rs:917`](../../core/dr-catalog/src/schema.rs#L917), [`core/dr-face/src/references.rs:1`](../../core/dr-face/src/references.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1`](../../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:1005`](../../ui/dr-ui/src/export.rs#L1005), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../../ui/dr-ui/src/trash.rs#L1) | | NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:272`](../../core/dr-types/src/lib.rs#L272), [`core/dr-types/src/lib.rs:305`](../../core/dr-types/src/lib.rs#L305), [`platform/dr-plat/src/dirs.rs:1`](../../platform/dr-plat/src/dirs.rs#L1), [`platform/dr-plat/src/display.rs:1`](../../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/storage.rs:1`](../../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:287`](../../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../../platform/dr-plat/src/storage.rs#L344), [`platform/dr-plat/src/volumes.rs:1`](../../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../../platform/dr-plat/src/volumes.rs#L62) | | NFR-PORT-2 | [`core/dr-gpu/src/lib.rs:1`](../../core/dr-gpu/src/lib.rs#L1) | | NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../../platform/dr-plat/src/storage.rs#L1) | -| NFR-R1 | [`core/dr-catalog/src/schema.rs:361`](../../core/dr-catalog/src/schema.rs#L361), [`core/dr-catalog/src/sync.rs:1`](../../core/dr-catalog/src/sync.rs#L1), [`core/dr-gpu/src/lib.rs:194`](../../core/dr-gpu/src/lib.rs#L194), [`core/dr-sync/src/account.rs:240`](../../core/dr-sync/src/account.rs#L240), [`ui/dr-ui/src/library.rs:1234`](../../ui/dr-ui/src/library.rs#L1234) | +| NFR-R1 | [`core/dr-catalog/src/schema.rs:361`](../../core/dr-catalog/src/schema.rs#L361), [`core/dr-catalog/src/sync.rs:1`](../../core/dr-catalog/src/sync.rs#L1), [`core/dr-gpu/src/lib.rs:194`](../../core/dr-gpu/src/lib.rs#L194), [`core/dr-sync/src/account.rs:240`](../../core/dr-sync/src/account.rs#L240), [`ui/dr-ui/src/library/paths.rs:69`](../../ui/dr-ui/src/library/paths.rs#L69) | | NFR-R2 | [`core/dr-catalog/src/recovery.rs:1`](../../core/dr-catalog/src/recovery.rs#L1), [`core/dr-catalog/src/recovery.rs:222`](../../core/dr-catalog/src/recovery.rs#L222), [`core/dr-catalog/src/sync.rs:61`](../../core/dr-catalog/src/sync.rs#L61), [`core/dr-catalog/src/trash.rs:1`](../../core/dr-catalog/src/trash.rs#L1), [`ui/dr-ui/src/derived_sync.rs:613`](../../ui/dr-ui/src/derived_sync.rs#L613), [`ui/dr-ui/src/derived_sync.rs:736`](../../ui/dr-ui/src/derived_sync.rs#L736), [`ui/dr-ui/src/library_ui.rs:4174`](../../ui/dr-ui/src/library_ui.rs#L4174), [`ui/dr-ui/src/library_ui.rs:4492`](../../ui/dr-ui/src/library_ui.rs#L4492) | | NFR-R3 | [`core/dr-decode/src/error.rs:30`](../../core/dr-decode/src/error.rs#L30) | -| NFR-R4 | [`core/dr-types/src/settings.rs:92`](../../core/dr-types/src/settings.rs#L92), [`ui/dr-ui/src/library.rs:1627`](../../ui/dr-ui/src/library.rs#L1627), [`ui/dr-ui/src/library.rs:1643`](../../ui/dr-ui/src/library.rs#L1643), [`ui/dr-ui/src/library_ui.rs:3427`](../../ui/dr-ui/src/library_ui.rs#L3427), [`ui/dr-ui/src/library_ui.rs:392`](../../ui/dr-ui/src/library_ui.rs#L392), [`ui/dr-ui/src/library_ui.rs:537`](../../ui/dr-ui/src/library_ui.rs#L537), [`ui/dr-ui/src/settings_ui.rs:540`](../../ui/dr-ui/src/settings_ui.rs#L540), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/ui/settings.slint:1093`](../../ui/dr-ui/ui/settings.slint#L1093), [`ui/dr-ui/ui/settings.slint:199`](../../ui/dr-ui/ui/settings.slint#L199) | +| NFR-R4 | [`core/dr-types/src/settings.rs:92`](../../core/dr-types/src/settings.rs#L92), [`ui/dr-ui/src/library/xmp.rs:25`](../../ui/dr-ui/src/library/xmp.rs#L25), [`ui/dr-ui/src/library/xmp.rs:9`](../../ui/dr-ui/src/library/xmp.rs#L9), [`ui/dr-ui/src/library_ui.rs:3427`](../../ui/dr-ui/src/library_ui.rs#L3427), [`ui/dr-ui/src/library_ui.rs:392`](../../ui/dr-ui/src/library_ui.rs#L392), [`ui/dr-ui/src/library_ui.rs:537`](../../ui/dr-ui/src/library_ui.rs#L537), [`ui/dr-ui/src/settings_ui.rs:540`](../../ui/dr-ui/src/settings_ui.rs#L540), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/ui/settings.slint:1093`](../../ui/dr-ui/ui/settings.slint#L1093), [`ui/dr-ui/ui/settings.slint:199`](../../ui/dr-ui/ui/settings.slint#L199) | | NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../../core/dr-catalog/src/schema.rs#L1) | | NFR-R6 | [`core/dr-catalog/src/error.rs:1`](../../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/lib.rs:255`](../../core/dr-catalog/src/lib.rs#L255), [`core/dr-catalog/src/recovery.rs:1`](../../core/dr-catalog/src/recovery.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../../core/dr-gpu/src/error.rs#L1) | -| NFR-RES-1 | [`core/dr-gpu/src/adjust.rs:1057`](../../core/dr-gpu/src/adjust.rs#L1057), [`core/dr-pipeline/src/history.rs:86`](../../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/develop.rs:4554`](../../ui/dr-ui/src/develop.rs#L4554), [`ui/dr-ui/src/identity_ui.rs:125`](../../ui/dr-ui/src/identity_ui.rs#L125), [`ui/dr-ui/src/lib.rs:100`](../../ui/dr-ui/src/lib.rs#L100), [`ui/dr-ui/src/memory.rs:1`](../../ui/dr-ui/src/memory.rs#L1) | -| NFR-RES-2 | [`ui/dr-ui/src/faces.rs:462`](../../ui/dr-ui/src/faces.rs#L462), [`ui/dr-ui/src/library.rs:3624`](../../ui/dr-ui/src/library.rs#L3624), [`ui/dr-ui/src/repairs.rs:1140`](../../ui/dr-ui/src/repairs.rs#L1140), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1) | +| NFR-RES-1 | [`core/dr-gpu/src/adjust.rs:1057`](../../core/dr-gpu/src/adjust.rs#L1057), [`core/dr-pipeline/src/history.rs:86`](../../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/develop/render.rs:519`](../../ui/dr-ui/src/develop/render.rs#L519), [`ui/dr-ui/src/identity_ui.rs:125`](../../ui/dr-ui/src/identity_ui.rs#L125), [`ui/dr-ui/src/lib.rs:100`](../../ui/dr-ui/src/lib.rs#L100), [`ui/dr-ui/src/memory.rs:1`](../../ui/dr-ui/src/memory.rs#L1) | +| NFR-RES-2 | [`ui/dr-ui/src/faces.rs:462`](../../ui/dr-ui/src/faces.rs#L462), [`ui/dr-ui/src/library/sweep.rs:241`](../../ui/dr-ui/src/library/sweep.rs#L241), [`ui/dr-ui/src/repairs.rs:1140`](../../ui/dr-ui/src/repairs.rs#L1140), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1) | | NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:1182`](../../core/dr-catalog/src/schema.rs#L1182), [`core/dr-gpu/src/lib.rs:95`](../../core/dr-gpu/src/lib.rs#L95), [`core/dr-thumbs/src/codec.rs:1`](../../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:393`](../../core/dr-thumbs/src/lib.rs#L393) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../../core/dr-decode/src/error.rs#L1), [`core/dr-decode/src/error.rs:30`](../../core/dr-decode/src/error.rs#L30) | | NFR-SEC-2 | [`core/dr-sync/src/account.rs:318`](../../core/dr-sync/src/account.rs#L318), [`platform/dr-plat/src/crash.rs:1`](../../platform/dr-plat/src/crash.rs#L1), [`platform/dr-plat/src/diagnostics.rs:1`](../../platform/dr-plat/src/diagnostics.rs#L1), [`platform/dr-plat/src/diagnostics/bundle.rs:1`](../../platform/dr-plat/src/diagnostics/bundle.rs#L1), [`platform/dr-plat/src/diagnostics/redact.rs:1`](../../platform/dr-plat/src/diagnostics/redact.rs#L1), [`platform/dr-plat/src/secrets.rs:82`](../../platform/dr-plat/src/secrets.rs#L82) | diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs deleted file mode 100644 index b9b8054..0000000 --- a/ui/dr-ui/src/develop.rs +++ /dev/null @@ -1,9327 +0,0 @@ -//! The develop session — capabilities in, rendered image out. -//! -//! This is the only place the UI touches the pipeline, and it does so through -//! two calls: [`dr_pipeline::EditGraph::capabilities`] to learn what controls -//! to build, and `set_param` to change one. It never names an operation, and -//! it knows nothing about shaders. -//! -//! Whether a control is a slider or a switch follows from the parameter's -//! declared [`ParamKind`], not from which parameter it is (ARCH §4.3), so a -//! new operation appears in the panel with no change here (FR-DEV-3c). - -use std::borrow::Cow; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; - -use dr_decode::RawImage; -use dr_gpu::{ - AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, Histogram, - HistogramPass, MaskPass, RawHistogram, RawHistogramPass, -}; -use dr_pipeline::mask::{MaskLayer, MaskSource}; - -use crate::segmentation::{self, Segmentation}; -use dr_pipeline::ops::curve; -use dr_pipeline::{ - CropRect, Edit, EditGraph, History, OpCapability, OpId, ParamId, ParamKind, Presentation, - Preset, Scope, Unit, WidgetKind, -}; - -use crate::labels; -use crate::ParamRow; - -/// A loaded image plus its edit state. -/// Longest edge the model and the masks work at. -/// -/// ~1.3 MP at 3:2. Large enough that an outline is within a pixel or two of -/// where it belongs, small enough that a distance transform over it is a few -/// milliseconds and its field a few megabytes. -const SEGMENT_PROXY_EDGE: u32 = 1600; - -/// Which photograph a piece of background work was started for. -/// -/// Minted per session, never reused, and carried by the work rather than -/// looked up when it finishes. A segmentation takes most of a second, so the -/// user can be two frames further on by the time one lands, and the answer to -/// "is this still wanted" has to be decided from what the work *was* rather -/// than from what happens to be open. -/// -/// The alternative — a counter beside the session slot, bumped on every open — -/// is written from four places in `lib.rs` and would apply one photograph's -/// subjects to another the first time somebody added a fifth and forgot. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SessionId(u64); - -impl SessionId { - pub(crate) fn next() -> Self { - static NEXT: AtomicU64 = AtomicU64::new(1); - Self(NEXT.fetch_add(1, Ordering::Relaxed)) - } -} - -/// Says that nobody is waiting for a job's answer any more. -/// -/// Not a cancellation in the sense of stopping the work: the model is one -/// opaque call of about half a second and `ort` offers no way in. This is -/// checked at the seams there are — before the job starts, and again between -/// the proxy readback and the inference — so a job abandoned while the user -/// was still paging usually costs nothing, and one abandoned mid-inference -/// costs only the run it was already committed to. -/// -/// What it buys in every case is that the next photograph's segmentation is -/// the only one anybody is waiting on. -#[derive(Debug, Clone, Default)] -pub struct Abandon(Arc); - -impl Abandon { - pub fn now(&self) { - self.0.store(true, Ordering::Relaxed); - } - - pub fn asked(&self) -> bool { - self.0.load(Ordering::Relaxed) - } -} - -/// What a finished [`SegmentationJob`] hands back. -/// -/// The rasteriser travels with the subjects because it is needed the instant -/// they arrive and nowhere before. Building it is a shader compile — 23 ms on -/// a desktop, and compiling shaders is among the slowest things a mobile -/// driver does — so building it on adoption put a dropped frame on the one -/// redraw the user is waiting for. Here it is on the thread that was waiting -/// anyway. -/// -/// `None` where the device has no mask rasteriser at all: the session keeps -/// the photograph and loses local adjustments, which is the same bargain the -/// histogram makes. -pub struct Segmented { - seg: Segmentation, - masks: Option, -} - -/// TRACES: FR-DEV-3 -/// A segmentation lifted out of the session that asked for it. -/// -/// [`DevelopSession`] cannot go to a worker. Not because of what it holds — -/// the device, the source texture and the passes are all `Send` — but because -/// it lives behind one `Rc>>` that every callback in the -/// window reaches through, and the window has to keep reaching through it -/// while the work runs. Handing the session over would freeze the interface -/// exactly as thoroughly as blocking on it did. -/// -/// So the work takes a copy of the two things it needs. The device is `Arc`s, -/// the source is shared rather than copied, and the answer comes back as plain -/// data. -pub struct SegmentationJob { - ctx: GpuContext, - source: Arc, - session: SessionId, - abandon: Abandon, - /// TRACES: FR-CULL-10 - /// Confirmed faces in this photograph, **normalised to the long edge** of - /// the EXIF-upright image. - /// - /// Carried rather than looked up, because the job runs on a thread with no - /// catalog in reach — the same reason it carries the pixels. Normalised - /// rather than in pixels because the proxy size is only settled inside - /// `run`. - names: Vec, - /// TRACES: FR-CULL-10 - /// The file's EXIF turn alone, which is the space `names` is expressed in. - /// - /// **Deliberately not [`SegmentationJob::orientation`].** Faces are found - /// on the thumbnail, which is stood up by the EXIF tag and knows nothing - /// about the photographer's later turns; the segmentation below composes - /// both. Using the composed one here would turn the faces twice on any - /// photograph the user has rotated, and the failure would be silent — - /// names simply landing on nobody. - exif_orientation: dr_types::Orientation, - /// TRACES: FR-DEV-3h - /// How the sensor's pixels have to be turned to be the photograph. - /// - /// The file's EXIF tag and the photographer's own turns, composed into - /// one permutation by `Framing::effective_orientation`. Carried rather - /// than read from the session for the reason everything else here is: the - /// job runs on a worker and the session stays behind. - /// - /// A *snapshot*, so turning the photograph while a run is in flight - /// leaves that run answering the question it was asked. The next press - /// takes the new one, and the signature says the two are different runs. - orientation: dr_types::Orientation, -} - -impl SegmentationJob { - /// The photograph this was started for. - pub fn session(&self) -> SessionId { - self.session - } - - /// The handle that tells this job its answer is no longer wanted. - pub fn abandon(&self) -> Abandon { - self.abandon.clone() - } - - /// TRACES: FR-DEV-3 - /// Find the subjects. **Blocking, and roughly two thirds of a second.** - /// - /// `Ok(None)` means abandoned rather than found-nothing: an image with no - /// recognisable subject in it still comes back as `Ok(Some(_))` with an - /// empty instance list, and the panel says so. - /// - /// There is deliberately no `&mut DevelopSession` in scope here. That is - /// the whole point of the split — a caller cannot accidentally hold the - /// session across the half second, because it was never given one. - pub fn run(&self, options: &segmentation::Options) -> Result, String> { - if self.abandon.asked() { - return Ok(None); - } - - // Timed and logged because this is the feature's largest cost and the - // split between the two halves decides where any further work goes. A - // number from the device it actually runs on beats an estimate from - // the desktop. - let started = std::time::Instant::now(); - let (rgb, rw, rh) = self.neutral_proxy(SEGMENT_PROXY_EDGE)?; - let proxied = started.elapsed(); - - // The one seam inside the run. Past here the model owns the thread - // until it is done. - if self.abandon.asked() { - return Ok(None); - } - - let mut seg = segmentation::compute(&self.ctx, &rgb, rw, rh, self.orientation, options)?; - - // TRACES: FR-CULL-10 - // Put names on the people the segmenter found. - // - // `compute` stands the frame up to detect and lays the instances back - // down, so `bbox` is in the sensor's space. The faces came off the - // thumbnail and are in the EXIF-upright one. Two different spaces, and - // on a portrait photograph they are a quarter turn apart — so the - // faces are turned down to meet the instances, through the same - // `Orientation` map every other consumer uses rather than a second - // copy of the arithmetic. - // - // The catalog normalises a face to the image's **long edge**, where - // `ShownRect` is normalised per axis; the conversions either side of - // the turn are that difference and nothing more. - if !self.names.is_empty() { - let long_edge = rw.max(rh) as f32; - let (dw, dh) = self.exif_orientation.oriented_size(rw as u32, rh as u32); - let (dw, dh) = (dw as f32, dh as f32); - - let boxes: Vec> = self - .names - .iter() - .map(|(x, y, w, h, name)| { - let shown = dr_types::ShownRect { - x: x * long_edge / dw, - y: y * long_edge / dh, - width: w * long_edge / dw, - height: h * long_edge / dh, - }; - let stored = self.exif_orientation.into_stored_rect(shown); - dr_face::NamedFace { - bbox: ( - stored.x * rw as f32, - stored.y * rh as f32, - (stored.x + stored.width) * rw as f32, - (stored.y + stored.height) * rh as f32, - ), - name, - } - }) - .collect(); - - let named = seg.apply_names(&boxes); - if named > 0 { - log::info!("named {named} segmented region(s) from known faces"); - } - } - - log::info!( - "segmented {rw}×{rh}: {} subject(s), proxy {:.0} ms, total {:.0} ms", - seg.instances().len(), - proxied.as_secs_f32() * 1000.0, - started.elapsed().as_secs_f32() * 1000.0, - ); - - let masks = MaskPass::new(&self.ctx) - .inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}")) - .ok(); - Ok(Some(Segmented { seg, masks })) - } - - /// Render the *unedited* image to a CPU buffer at proxy size. - /// - /// The model reads the photograph as captured, not as edited: the - /// segmentation must survive an exposure change, or every slider would - /// invalidate the masks that depend on it (docs/dev/segmentation.md §3). - /// - /// **Still the sensor's orientation, deliberately.** Standing the picture - /// up is what `segmentation::compute` does to the buffer this returns, - /// and it is done there rather than here because a mask has to come back - /// in this space: the generated shader samples the mask array at `uv_src`, - /// after the framing map. Rendering an upright proxy would put every mask - /// a quarter turn away from the subject it was drawn around — a wrong - /// mask rather than a weak one, and nothing would announce it. - /// - /// A throwaway [`AdjustPass`] with a neutral graph rather than the - /// session's own — which this could not reach from here in any case, and - /// must not: reusing it would overwrite the frame the histogram reads and - /// leave the view showing an unedited image until the next redraw. - /// - /// This is `export_pixels`, which is ungated: an export is not the display - /// round-trip AC-8 forbids, and neither is this. - fn neutral_proxy(&self, max_edge: u32) -> Result<(Vec, usize, usize), String> { - let (sw, sh) = self.source.size(); - let scale = (max_edge as f32 / sw.max(sh) as f32).min(1.0); - let (w, h) = ( - ((sw as f32 * scale) as u32).max(1), - ((sh as f32 * scale) as u32).max(1), - ); - - let neutral = EditGraph::default_chain(); - let mut pass = AdjustPass::new(&self.ctx); - pass.render(&self.source, &neutral.compose(), w, h) - .map_err(|e| format!("could not render the segmentation proxy: {e}"))?; - let (rgba, pw, ph) = pass - .export_pixels() - .map_err(|e| format!("could not read the segmentation proxy: {e}"))?; - - // Straight to float RGB, dropping alpha. The values stay display- - // encoded because that is what the model was trained on — one of the - // few places in this codebase where not linearising is correct. - let rgb = rgba - .chunks_exact(4) - .flat_map(|p| { - [ - p[0] as f32 / 255.0, - p[1] as f32 / 255.0, - p[2] as f32 / 255.0, - ] - }) - .collect(); - Ok((rgb, pw as usize, ph as usize)) - } -} - -/// Longest edge the refine crop is rendered at. -/// -/// Matches [`dr_segment::semantic::INPUT_EDGE`] rather than exceeding it: the -/// model's own input is still fixed at 640x640, so rendering the crop larger -/// only gets downsampled again inside the model's letterbox. The resolution -/// win is entirely from *what fills the window* — a padded crop around one -/// subject rather than the whole frame — not from feeding the model more -/// pixels than it has ever read. -const REFINE_EDGE: u32 = 640; - -/// How much of the box's own size is added on each side before cropping. -/// -/// Context for the model to place the subject's edge against, and slack for -/// a box that under-ran the subject slightly on the first pass. Not so much -/// that a second subject standing nearby gets pulled into the same window. -const REFINE_PADDING: f32 = 0.25; - -/// TRACES: FR-DEV-3 -/// A refine pass lifted out of the session that asked for it. -/// -/// The same split as [`SegmentationJob`], for the same reason — see its -/// docs. This one answers for a single already-detected instance rather than -/// the whole frame: it re-runs the model over a padded crop of just that -/// subject's box, so the subject reaches the model at its own size instead -/// of squeezed into the model's fixed window alongside everything else in -/// the photograph. -pub struct RefineJob { - ctx: GpuContext, - source: Arc, - session: SessionId, - abandon: Abandon, - /// Which instance this answers for. A snapshot of what it needs from the - /// segmentation, taken when the job was built — the same reason - /// `SegmentationJob` carries a proxy render rather than the session. - index: usize, - class_name: Arc, - bbox: (f32, f32, f32, f32), - proxy: (usize, usize), - /// The same permutation, for the same reason — see - /// [`SegmentationJob::orientation`]. A refine pass that read the crop - /// sideways would hand back a worse mask than the one it was asked to - /// improve, on the subject the photographer had just pointed at. - orientation: dr_types::Orientation, -} - -impl RefineJob { - pub fn session(&self) -> SessionId { - self.session - } - - pub fn abandon(&self) -> Abandon { - self.abandon.clone() - } - - /// TRACES: FR-DEV-3 - /// Re-detect this one subject at higher effective resolution. **Blocking.** - /// - /// `Ok(None)` covers both "abandoned" and "the model found nothing of the - /// same class in the crop" — a photographer pressing refine on a subject - /// that the padded window no longer contains is a possible outcome, not - /// a bug, and the caller treats it as "kept what was there" either way. - pub fn run(&self) -> Result, String> { - if self.abandon.asked() { - return Ok(None); - } - - let (px0, py0, px1, py1) = self.bbox; - let (pw, ph) = (self.proxy.0 as f32, self.proxy.1 as f32); - if pw <= 0.0 || ph <= 0.0 { - return Ok(None); - } - - let (bw, bh) = ((px1 - px0).max(1.0), (py1 - py0).max(1.0)); - let (padx, pady) = (bw * REFINE_PADDING, bh * REFINE_PADDING); - let x0 = (px0 - padx).max(0.0); - let y0 = (py0 - pady).max(0.0); - let x1 = (px1 + padx).min(pw); - let y1 = (py1 + pady).min(ph); - if x1 <= x0 || y1 <= y0 { - return Ok(None); - } - let (cw_px, ch_px) = (x1 - x0, y1 - y0); - - let view = CropRect { - x: x0 / pw, - y: y0 / ph, - width: cw_px / pw, - height: ch_px / ph, - }; - - // Aspect-matched render target, capped against blowing a tiny box up - // absurdly far past what the source ever had to offer. - let scale = (REFINE_EDGE as f32 / cw_px.max(ch_px)).min(4.0); - let (tw, th) = ( - (cw_px * scale).round().max(1.0) as u32, - (ch_px * scale).round().max(1.0) as u32, - ); - - if self.abandon.asked() { - return Ok(None); - } - let (rgb, rw, rh) = self.render_view(view, tw, th)?; - if self.abandon.asked() { - return Ok(None); - } - - // Stood up before the model reads it and laid back down after, the - // same way the whole-frame pass does it — `segmentation::upright` is - // the one place that permutation is written. A crop rendered in - // sensor space is exactly as sideways as the frame it came from. - let (upright, uw, uh) = segmentation::upright(&rgb, rw, rh, self.orientation); - - let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?; - let options = dr_segment::SemanticOptions { - tiling: dr_segment::Tiling::Whole, - ..dr_segment::SemanticOptions::default() - }; - let found = model - .detect(&upright, uw, uh, &options) - .map_err(|e| e.to_string())?; - - // The crop was built around one subject, so the right answer among - // whatever the model found in it is the same class closest to the - // window's centre — not merely the highest score, which a second, - // unrelated instance caught in the padding could win. - // - // Measured in the upright frame, which is where the model's boxes are. - // The centre is the centre either way; the distances are not, once the - // window is not square. - let (cx, cy) = (uw as f32 * 0.5, uh as f32 * 0.5); - let best = found - .into_iter() - .filter(|i| i.class_name.as_ref() == self.class_name.as_ref()) - .min_by(|a, b| centre_distance(a, cx, cy).total_cmp(¢re_distance(b, cx, cy))); - - let Some(instance) = best else { - return Ok(None); - }; - - // Back into the crop's own sensor-space pixels, so everything below - // this line measures in the space `self.bbox` and `self.proxy` are in. - let (crop_mask, crop_bbox) = - segmentation::lay_down(&instance.mask, instance.bbox, uw, uh, self.orientation); - - // Downsampled back onto the shared proxy grid like every other - // instance's mask is, but built from a sharper source than the - // whole-frame pass ever saw for this subject. - let mask = paste_into_proxy(&crop_mask, rw, rh, self.proxy, (x0, y0), (cw_px, ch_px)); - let bbox = ( - x0 + crop_bbox.0 / scale, - y0 + crop_bbox.1 / scale, - x0 + crop_bbox.2 / scale, - y0 + crop_bbox.3 / scale, - ); - - Ok(Some(RefinedInstance { - index: self.index, - summary: segmentation::InstanceSummary { - class_name: instance.class_name, - score: instance.score, - mask, - bbox, - }, - })) - } - - /// Render the *unedited* image, showing only `view`, at `(width, height)`. - /// - /// The same neutral, as-captured render [`SegmentationJob::neutral_proxy`] - /// uses — a refine pass must read the same kind of pixels the first pass - /// did, or a subject would gain or lose an edge depending on which pass - /// found it. `view` is framing's ephemeral viewport (`Framing::set_view`), - /// the mechanism the on-screen zoom already uses to render a region at - /// more than proxy resolution — not the crop tool's own persisted - /// rectangle, and nothing here touches that. - fn render_view( - &self, - view: CropRect, - width: u32, - height: u32, - ) -> Result<(Vec, usize, usize), String> { - let mut neutral = EditGraph::default_chain(); - neutral.framing_mut().set_view(view); - - let mut pass = AdjustPass::new(&self.ctx); - pass.render(&self.source, &neutral.compose(), width, height) - .map_err(|e| format!("could not render the refine crop: {e}"))?; - let (rgba, pw, ph) = pass - .export_pixels() - .map_err(|e| format!("could not read the refine crop: {e}"))?; - - let rgb = rgba - .chunks_exact(4) - .flat_map(|p| { - [ - p[0] as f32 / 255.0, - p[1] as f32 / 255.0, - p[2] as f32 / 255.0, - ] - }) - .collect(); - Ok((rgb, pw as usize, ph as usize)) - } -} - -/// What a finished [`RefineJob`] hands back: a replacement for one instance -/// in the segmentation it was run against. -pub struct RefinedInstance { - index: usize, - summary: segmentation::InstanceSummary, -} - -fn centre_distance(instance: &dr_segment::Instance, cx: f32, cy: f32) -> f32 { - let (x0, y0, x1, y1) = instance.bbox; - let (ix, iy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5); - ((ix - cx).powi(2) + (iy - cy).powi(2)).sqrt() -} - -/// Sample a crop-local mask back onto its footprint in the shared proxy grid. -/// -/// Bilinear, the same as every other resampling in this mask pipeline -/// (`dr_segment::semantic`'s own prototype sampling, the letterbox that feeds -/// it) — correct whether the crop was rendered denser than the proxy (the -/// common case this feature exists for) or coarser than it (a subject large -/// enough that refining it buys little, which still renders a sensible if -/// unremarkable answer rather than a distorted one). -fn paste_into_proxy( - crop_mask: &[f32], - crop_w: usize, - crop_h: usize, - proxy: (usize, usize), - origin_px: (f32, f32), - extent_px: (f32, f32), -) -> Vec { - let (pw, ph) = proxy; - let mut out = vec![0u8; pw * ph]; - let (ew, eh) = extent_px; - if crop_w == 0 || crop_h == 0 || pw == 0 || ph == 0 || ew <= 0.0 || eh <= 0.0 { - return out; - } - - let (ox, oy) = origin_px; - let px0 = ox.floor().max(0.0) as usize; - let py0 = oy.floor().max(0.0) as usize; - let px1 = ((ox + ew).ceil() as usize).min(pw); - let py1 = ((oy + eh).ceil() as usize).min(ph); - - for py in py0..py1 { - let gy = (py as f32 + 0.5 - oy) / eh * crop_h as f32; - if gy < 0.0 || gy >= crop_h as f32 { - continue; - } - for px in px0..px1 { - let gx = (px as f32 + 0.5 - ox) / ew * crop_w as f32; - if gx < 0.0 || gx >= crop_w as f32 { - continue; - } - let v = bilinear_sample(crop_mask, crop_w, crop_h, gx, gy); - out[py * pw + px] = (v.clamp(0.0, 1.0) * 255.0).round() as u8; - } - } - out -} - -fn bilinear_sample(mask: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 { - let (fx0, fy0) = (x.floor(), y.floor()); - let (fx, fy) = (x - fx0, y - fy0); - let x0 = (fx0 as isize).clamp(0, w as isize - 1) as usize; - let y0 = (fy0 as isize).clamp(0, h as isize - 1) as usize; - let x1 = (x0 + 1).min(w - 1); - let y1 = (y0 + 1).min(h - 1); - let at = |x: usize, y: usize| mask[y * w + x]; - let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx; - let bot = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx; - top * (1.0 - fy) + bot * fy -} - -/// TRACES: FR-DEV-3 -/// A shape the crop rectangle is held to while it is dragged. -/// -/// A photographer cropping for a print, a phone wallpaper or a 16:9 frame is -/// not choosing four edges — they are choosing one edge and a known shape, and -/// a free crop makes them do the arithmetic by eye on every drag. This is the -/// lock that removes it. -/// -/// **The ratio is of output pixels, not of the rect's own numbers.** The rect -/// is stored in fractions of a frame that is not square, so `CropRect` needs -/// the frame's size to hold a shape; see [`CropRect::with_aspect`], which is -/// where that conversion is done and explained. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum CropAspect { - /// Any shape. The handles move independently, as they always have. - #[default] - Free, - /// Whatever the frame already is, so a crop trims without reshaping. - /// - /// Not the same as `Fixed(3, 2)` even on a 3:2 camera: it follows the - /// frame, so it stays right on the next photograph from another body and - /// after a quarter turn. - Original, - /// A named ratio of `w:h`, before the portrait switch is applied. - Fixed(u32, u32), -} - -impl CropAspect { - /// The ratios the panel offers, in the order it draws them. - /// - /// Short on purpose. These sit as chips in a column narrow enough for a - /// tablet, and every ratio a photographer reaches for repeatedly is here: - /// the frame's own shape, the square, the two classic camera ratios, the - /// large-format one that most print papers follow, and video's. - pub const CHOICES: [Self; 6] = [ - Self::Free, - Self::Original, - Self::Fixed(1, 1), - Self::Fixed(3, 2), - Self::Fixed(4, 3), - Self::Fixed(16, 9), - ]; - - /// The chip's text. - pub fn label(self) -> String { - match self { - Self::Free => "Free".to_string(), - Self::Original => "Original".to_string(), - Self::Fixed(w, h) => format!("{w}:{h}"), - } - } - - /// Whether this choice has a portrait form at all. - /// - /// A square does not, and neither does `Free`. The switch is disabled - /// rather than hidden for those, so the row does not change shape as the - /// chips are tried. - pub fn has_orientation(self) -> bool { - !matches!(self, Self::Free | Self::Fixed(1, 1)) - } - - /// TRACES: FR-DEV-3 - /// Whether a quarter turn of the frame has to flip the orientation switch - /// to leave this ratio describing the same shape. - /// - /// A quarter turn carries the crop with it — that is what makes turning a - /// photograph keep its composition — so a rect locked to 16:9 comes out of - /// the turn at 9:16, and the switch has to agree or the next drag would - /// snap the crop back and undo the turn's effect on it. - /// - /// `Original` is deliberately *not* included, and getting that wrong flips - /// it twice. It is resolved against the framed size every time it is - /// asked for, and a quarter turn swaps that frame's axes — so it has - /// already turned by the time anything asks. - pub fn turns_with_the_frame(self) -> bool { - matches!(self, Self::Fixed(w, h) if w != h) - } - - /// Width over height in output pixels, or `None` where nothing is locked. - /// - /// `frame` is the framed size the crop is measured against — the turned - /// frame, not the sensor — which is what makes `Original` follow a quarter - /// turn instead of becoming a portrait crop on a landscape photograph. - pub fn ratio(self, frame: (u32, u32), portrait: bool) -> Option { - let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32); - let landscape = match self { - Self::Free => return None, - Self::Original => fw / fh, - Self::Fixed(w, h) => w.max(1) as f32 / h.max(1) as f32, - }; - Some(if portrait && self.has_orientation() { - 1.0 / landscape - } else { - landscape - }) - } -} - -/// TRACES: FR-DEV-19c -/// How one layer's mask is shown on the canvas. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct MaskView { - shown: bool, - /// Index into [`MASK_COLOURS`]. - colour: usize, -} - -/// TRACES: FR-DEV-19c -/// The colours a mask may be shown in, in linear sRGB. -/// -/// Six, chosen to be told apart at half strength over a photograph rather -/// than to be pretty: red and green and blue at the corners, and the three -/// between them. Exposed so the panel draws its swatches from the same table -/// the shader is handed, and a seventh colour is one line here and nowhere -/// else. -pub const MASK_COLOURS: [[f32; 3]; 6] = [ - [0.85, 0.10, 0.15], - [0.15, 0.80, 0.25], - [0.20, 0.45, 1.00], - [0.95, 0.80, 0.10], - [0.90, 0.20, 0.85], - [0.15, 0.85, 0.90], -]; - -pub struct DevelopSession { - /// This session's name, for work that outlives the frame it started on. - id: SessionId, - /// TRACES: FR-CULL-10 - /// Confirmed faces in this photograph, normalised to the long edge. - /// - /// Empty until [`DevelopSession::set_face_names`] is called, and empty for - /// ever on a library with no face indexing — in which case segmentation - /// behaves exactly as it did before, which is the point. - face_names: Vec, - /// How this photograph is stored relative to how it is shown. - /// - /// Kept because the segmentation proxy is rendered through a *neutral* - /// graph and is therefore in sensor order, while faces were found on the - /// 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, - /// Whether the lens in the header matched a profile in the database. - /// - /// A separate flag rather than `graph.lens_profile().is_some()`, because - /// the two answer different questions once the photographer starts work: - /// the graph says what is *applied*, which a manual correction also - /// satisfies, and this says whether a *measurement* was found. Only the - /// second can honestly caption "no profile". - lens_profile_found: bool, - /// Kept so the session can build GPU resources after construction. - /// - /// The distance fields behind a subject mask are made when a layer is - /// *shaped*, not when the image opens, and cloning a `GpuContext` is two - /// `Arc` bumps. - ctx: GpuContext, - graph: EditGraph, - /// TRACES: FR-DEV-5 - /// Undo, kept beside the graph rather than in the window. - /// - /// Every mutator below records into it, so a caller cannot change the edit - /// and forget to. That is the whole reason it lives here: the callbacks in - /// `lib.rs` are generic by construction and there are a dozen of them, and - /// a history the *call sites* had to remember would be one press of undo - /// away from wrong every time a control is added. - history: History, - /// TRACES: FR-DEV-5 - /// The named snapshots of this edit, as the sidecar had them plus what - /// this sitting took, minus what it deleted. - /// - /// Beside the history rather than inside it, because they answer a - /// different question. The history is what was done in this sitting and - /// is deliberately forgotten with it; a snapshot is a state the - /// photographer *named*, which is the act of saying it should outlive - /// the sitting. It is persisted as a version of the sidecar pointing at - /// this one (`Version::snapshot_of`), which is what makes it survive a - /// restart and reach the other device. - snapshots: Vec, - /// The ids of snapshots deleted this sitting, so the save can remove - /// them from the file without removing what another device added since - /// — see `Sidecar::replace_snapshots`. - removed_snapshots: Vec, - /// TRACES: FR-DEV-7 - /// The snapshot the canvas is showing instead of the edit, while a - /// comparison is held. Viewing state: nothing about the edit changes, - /// and it goes down with the session. - compared_snapshot: Option, - demosaiced: Arc, - adjust: AdjustPass, - /// TRACES: FR-DSP-7 - /// Optional, because a session that cannot count its frames is still a - /// session that can develop them. If the reduction fails to build — an - /// old driver, a device without the storage-buffer atomics it needs — the - /// photographer loses the histogram and keeps the photograph. - histogram: Option, - /// TRACES: FR-CULL-3 - /// The raw-domain reduction, on the same terms as the display one above: - /// optional, because a session that cannot count the sensor data is still - /// a session that can develop it. - raw_histogram: Option, - /// The raw reading, once taken. - /// - /// **Cached, where the display histogram is recomputed every settled - /// frame, and the difference is not an optimisation.** This measures the - /// demosaiced source, which nothing downstream of the demosaic can change: - /// no slider, no crop, no zoom, no output space moves a single count in - /// it. Recomputing it per frame would be a dispatch and a device sync - /// point spent to arrive back at the number already held — and on the - /// culling pass FR-CULL-3 is written for, that is a cost paid three - /// thousand times over. - /// - /// `None` until first asked for, and it stays `None` on a file with no - /// sensor data behind it. The session is one photograph and the demosaiced - /// source is fixed for its life, so there is no invalidation to get wrong. - raw_counts: Option, - /// TRACES: FR-CULL-3 - /// The focus-peaking overlay, on the same terms as the histogram above: - /// optional, because a device that cannot compile the pass is still a - /// device that can develop the photograph. What is lost is an instrument, - /// not the picture. - peak: Option, - /// TRACES: FR-CULL-3 - /// What the photographer asked the overlay to look like, or `None` for - /// off. - /// - /// **Interface state, not part of the edit** — the same category as - /// `show_overlay` beside it. It changes no pixel of the photograph, it is - /// not in the sidecar, and it is not on the undo stack: pressing undo - /// after switching peaking on should take back the last *edit*, not the - /// last thing looked at. - /// - /// An `Option` rather than a bool plus a settings field, so that "off" and - /// "on, in some configuration" cannot disagree with each other. - peaking: Option, - - /// TRACES: FR-DEV-3 - /// The region map local masks select from, once it has been computed. - /// - /// `None` until the photographer asks for it. Segmentation costs about - /// half a second and most edits never need one, so running it on open - /// would tax every photograph for a feature used on some of them. - segmentation: Option, - /// Rasterises the mask layers. Built lazily for the same reason. - masks: Option, - /// One signed distance field per active subject layer, on the GPU. - subjects: Option, - /// What `subjects` was built from. - /// - /// The fields are expensive — an exact distance transform over the proxy - /// for each layer — and almost nothing changes them. Feather, falloff and - /// simple growing are arithmetic the shader does on the field it already - /// has, so this deliberately does *not* include them: dragging those - /// sliders must not rebuild anything. - subject_key: u64, - /// Which layers the develop panel is editing, if any. - /// - /// This is what lets one panel serve both scopes: with layers selected, - /// the sliders read and write *their* chains, and the photographer is - /// adjusting one or more regions rather than the frame. - /// - /// A plain click replaces this outright; a modifier-click toggles one id - /// in or out, so several layers can be shaped by the same slider drag — - /// "make these three subjects a stop darker" is one gesture rather than - /// three. Order is insertion order and nothing reads it, only membership. - active_masks: Vec, - /// TRACES: FR-DEV-19a - /// Which part of the selected layer the tools and the edge controls point - /// at. Zero — the base — whenever a layer is selected afresh. - /// - /// An index rather than an id, because it addresses a row the panel is - /// already showing by position, and because a gesture that outlived the - /// part it was aimed at would be a stroke landing somewhere nobody asked - /// for. Clamped on the way in and re-checked on the way out. - active_part: usize, - /// TRACES: FR-DEV-19b - /// The layer and part a stroke in progress is going into. - /// - /// Captured on press and held for the gesture: the panel's selection can - /// change under a finger — a stray tap, a sync arriving — and a stroke - /// that changed target half way through would leave half a mark in each. - painting: Option<(String, usize)>, - /// TRACES: FR-DEV-19b - /// The brush: radius as a fraction of the frame's shorter edge, hardness, - /// and flow. - /// - /// On the session rather than on a layer, because it belongs to the - /// *tool*: somebody who sets a small eraser expects it to still be small - /// the next time they erase, whichever mask they are working on. - brush: (f32, f32, f32), - /// TRACES: FR-DEV-8 - /// Which repair the panel is describing, if any. - /// - /// Interface state and not part of the edit, exactly as `active_masks` is: - /// it changes no pixel, it is not in the sidecar, and it is not on the undo - /// stack. One at a time rather than a set — a repair is eight numbers and - /// there is no gesture that usefully moves several at once, where three - /// masked layers really can share a slider drag. - selected_spot: Option, - /// Whether to draw the false-coloured region overlay. - show_overlay: bool, - /// TRACES: FR-DEV-19c - /// How shown masks are drawn — one style for all of them. - /// - /// Interface state, like `show_overlay` beside it and `active_masks` above - /// — it changes no pixel of the photograph, it is not in the sidecar and - /// it is not on the undo stack. It reaches the pipeline as an argument to - /// the one composition that draws the canvas, which is what makes an - /// export structurally unable to carry it (`EditGraph::compose_revealing`). - reveal_style: dr_pipeline::mask::RevealStyle, - /// TRACES: FR-DEV-19c - /// Per layer: whether its mask is shown, and in what colour. - /// - /// Per layer rather than "the selected one", because the question a - /// photographer asks of two masks is how they meet — where the sky's edge - /// sits against the building's — and that needs both on screen at once, - /// in colours that can be told apart. Keyed by id, and an id that is no - /// longer in the stack is simply never asked for; `reveal` walks the - /// stack, not this map. - /// - /// Viewing state and not edit state, for the reason the style is: it does - /// not travel in a sidecar, so a photograph reopened has every eye closed. - mask_views: std::collections::HashMap, - /// Which attribute the panel is filtered to, or all of them. - /// - /// `None` is "show everything" and is what a frontend that ignores - /// attributes leaves it at — the tabs are the interface's idea, not the - /// core's, and nothing breaks without them (ARCH §4.3a). - active_tab: Option, - /// TRACES: FR-DEV-3 - /// Which of the curve widget's subjects the panel is plotting. - /// - /// The tone curve is four curves — one over tone and one per colour - /// channel — and one square plot draws one of them at a time. The index - /// is into the subjects the operation's parameters are faceted on, in the - /// order it declares them, so nothing here knows that "red" exists. - /// - /// **Interface state, not part of the edit.** It changes no pixel, so it - /// is not a parameter, it is not in the graph, it is not in the sidecar - /// and it is not on the undo stack — the same standing as which tab is - /// open. One value rather than one per operation, for the same reason - /// `curve_samples` is one polyline: the panel draws one curve. - curve_channel: usize, - /// TRACES: FR-DSP-8 - /// The space the canvas is encoded into, for the display now showing it. - /// - /// **Not part of the edit, and not interface state either.** It is a fact - /// about the glass in front of the photographer: the same graph on the - /// same file composes differently on a wide-gamut second monitor, and - /// neither the sidecar nor the undo stack has any business knowing about - /// it. That is also why it lives here rather than on the `EditGraph` — - /// `compose_for` deliberately takes the space per call because "the same - /// edit goes to the screen in the display's space and to a file in - /// whatever the export asks for, and neither is more authoritative". - /// - /// sRGB until the application says otherwise, which is the same answer - /// `dr_plat::display`'s fallback gives and means a session constructed in - /// a test behaves exactly as it did before this existed. - /// TRACES: FR-DEV-3 - /// What the straightening auto-crop last wrote, and what it was derived - /// from: `(applied, intended)`. - /// - /// **The graph holds the corrected rectangle; this remembers the intent - /// behind it.** `auto_crop_to_angle` pulls the crop inside the area an - /// angle leaves defined, and that operation can only ever shrink. Applied - /// to its own output it ratchets — straighten to 20 degrees, come back to - /// 3, and the crop stays at the size 20 degrees demanded, which is not - /// what turning the slider back means. So the correction is never - /// accumulated: it is recomputed from the intent every time, and as the - /// angle falls the crop grows back and stops exactly where the user put - /// it. At zero degrees the safe area is the whole frame and the two are - /// equal again. - /// - /// **A pair rather than a single remembered rectangle, so it repairs - /// itself.** Every other route to the crop — a handle dragged, a ratio - /// chosen, a sidecar loaded, a paste, an undo — leaves the graph holding - /// something other than `applied`, and that mismatch is exactly the signal - /// that the remembered intent is stale. [`Self::intended_crop`] checks it - /// rather than requiring each of those paths to remember to write here, - /// which is the kind of bookkeeping that is correct until someone adds a - /// seventh path. - /// - /// Session-scoped. A sidecar records the crop that was *applied*, because - /// that is the one that describes the photograph, so reopening starts from - /// that rectangle as its own intent. - auto_crop: Option<(CropRect, CropRect)>, - display_space: dr_types::ColourSpace, -} - -impl DevelopSession { - /// Demosaic an image and prepare its edit graph. - /// - /// `orientation` is the file's EXIF orientation, not an edit: a sensor is - /// scanned the same way whichever way the body was held, so this is what - /// makes a portrait frame open upright. It is fixed for the life of the - /// session and survives a reset. - pub fn open( - ctx: &GpuContext, - raw: &RawImage, - orientation: dr_types::Orientation, - ) -> Result { - let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?; - let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?; - Ok(Self::with_source(ctx, demosaiced, orientation)) - } - - /// Prepare an edit graph over an already-processed RGB image. - /// - /// The JPEG path. A JPEG is already demosaiced, so there is no sensor - /// stage to run — but everything after it is identical, which is why this - /// shares [`Self::with_source`] rather than duplicating the session. - /// - /// Worth being honest about what this cannot recover: an 8-bit JPEG has - /// clipped highlights and quantised shadows that no edit brings back, so - /// exposure has far less latitude here than on sensor data. The controls - /// are the same controls; the file simply carries less to work with. - pub fn open_rgb( - ctx: &GpuContext, - rgba: &[u8], - width: u32, - height: u32, - orientation: dr_types::Orientation, - ) -> Result { - let source = - DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?; - Ok(Self::with_source(ctx, source, orientation)) - } - - fn with_source( - ctx: &GpuContext, - demosaiced: DemosaicedImage, - orientation: dr_types::Orientation, - ) -> Self { - let mut graph = EditGraph::default_chain(); - graph.set_orientation(orientation); - let history = History::new(&graph); - Self { - 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, - // Nothing has been looked up, which is not the same as "looked up - // and not found" — `lens_summary` distinguishes them. - lens_profile_found: false, - ctx: ctx.clone(), - graph, - history, - snapshots: Vec::new(), - removed_snapshots: Vec::new(), - compared_snapshot: None, - demosaiced: Arc::new(demosaiced), - adjust: AdjustPass::new(ctx), - histogram: HistogramPass::new(ctx) - .inspect_err(|e| log::warn!("no histogram on this device: {e}")) - .ok(), - raw_histogram: RawHistogramPass::new(ctx) - .inspect_err(|e| log::warn!("no raw histogram on this device: {e}")) - .ok(), - raw_counts: None, - peak: FocusPeakPass::new(ctx) - .inspect_err(|e| log::warn!("no focus peaking on this device: {e}")) - .ok(), - peaking: None, - segmentation: None, - masks: None, - subjects: None, - subject_key: 0, - active_masks: Vec::new(), - active_part: 0, - painting: None, - brush: ( - dr_pipeline::mask::DEFAULT_BRUSH_RADIUS, - dr_pipeline::mask::DEFAULT_BRUSH_HARDNESS, - dr_pipeline::mask::DEFAULT_BRUSH_FLOW, - ), - selected_spot: None, - show_overlay: false, - reveal_style: dr_pipeline::mask::RevealStyle::Tint, - mask_views: std::collections::HashMap::new(), - active_tab: None, - curve_channel: 0, - display_space: dr_types::ColourSpace::Srgb, - auto_crop: None, - } - } - - /// The controls the interface should show. - /// - /// Built entirely from the capability list. The `kind` string chooses the - /// widget; nothing switches on a parameter's identity. - pub fn rows(&self) -> Vec { - let caps = self.scoped_capabilities(); - match self.active_tab { - Some(attribute) => rows_filtered( - &caps, - |op| op.attributes.contains(&attribute), - self.curve_channel, - ), - None => rows_filtered(&caps, |_| true, self.curve_channel), - } - } - - /// The capability list the panel is currently describing. - /// - /// A selected mask layer takes over the panel, so every control the global - /// chain offers is offered on a layer too — including operations added - /// later, which need no work to become local. - fn scoped_capabilities(&self) -> Vec { - match self.active_layer() { - Some(layer) => layer.capabilities(), - None => self.graph.capabilities(), - } - } - - /// The attributes worth offering as tabs, in declaration order. - /// - /// **Derived from the chain, never listed here.** The groups are whatever - /// the operations say they are about, so a new operation joins the right - /// tab by declaring its nature and this file goes on naming none of them - /// (FR-DEV-3a). An attribute nothing carries is left out rather than - /// offered as a tab that opens onto nothing. - /// - /// Compose is excluded: its one operation prefers an on-canvas widget and - /// is skipped by the row builder, so a Compose tab would be empty of rows - /// while `ComposePanel` holds the real controls. - pub fn tabs(&self) -> Vec<(dr_pipeline::Attribute, String)> { - use dr_pipeline::Attribute; - let caps = self.scoped_capabilities(); - Attribute::ALL - .into_iter() - .filter(|a| *a != Attribute::Compose) - .filter(|a| { - caps.iter().any(|c| { - c.attributes.contains(a) - && !rows_filtered(&caps, |o| o.attributes.contains(a), 0).is_empty() - }) - }) - .map(|a| (a, crate::labels::resolve(a.label().0))) - .collect() - } - - /// Which tab is selected, as an index into [`Self::tabs`]. `-1` is "all". - pub fn active_tab(&self) -> i32 { - let Some(active) = self.active_tab else { - return -1; - }; - self.tabs() - .iter() - .position(|(a, _)| *a == active) - .map_or(-1, |i| i as i32) - } - - /// TRACES: FR-DEV-3f - /// Whether the film stock belongs in the group currently on screen. - /// - /// The stock is not a parameter, so it is not a [`ParamRow`] and the tab - /// filter that hides every other control never reached it: the picker was - /// drawn above the rows in *every* group, so "Kodachrome" sat at the top of - /// Light, of Colour and of Detail alike. Three places it does not belong, - /// and the one it does was no more prominent than the rest. - /// - /// Answered here rather than in the panel because it is a question about - /// the operation — what is this control *about* — and the panel is not - /// allowed to know. It asks the descriptor, so a stock that were ever - /// re-declared as something other than an effect would move on its own. - pub fn film_in_group(&self) -> bool { - let Some(active) = self.active_tab else { - // "All" shows everything, the stock included. - return true; - }; - self.graph - .capabilities() - .iter() - .find(|c| c.id == dr_pipeline::ops::film_sim::ID) - .is_some_and(|c| c.attributes.contains(&active)) - } - - /// Select a tab by its index in [`Self::tabs`], or `-1` for all. - pub fn set_active_tab(&mut self, index: i32) { - self.active_tab = usize::try_from(index) - .ok() - .and_then(|i| self.tabs().get(i).map(|(a, _)| *a)); - } - - /// The selection's representative layer, for anything that can only show - /// one answer — which tab is open, what value a slider currently reads. - /// - /// The first id selected, not "the" active layer: with more than one - /// selected there is no single truth to show, and the panel has to pick - /// something. Whichever layer this is, [`Self::set_param`] and - /// [`Self::reset_op`] still write to every selected layer, not just this - /// one — a slider shows one number and applies it everywhere selected. - fn active_layer(&self) -> Option<&MaskLayer> { - let id = self.active_masks.first()?; - self.graph.masks().get(id) - } - - /// Every selected layer, mutably — what a batched slider or reset walks. - fn active_layers_mut(&mut self) -> impl Iterator { - let selected = self.active_masks.clone(); - self.graph - .masks_mut() - .layers_mut() - .iter_mut() - .filter(move |l| selected.contains(&l.id)) - } -} - -// The empty nested models, each a single shared identity. -// -// **`ModelRc` compares by identity, not by contents**, and `sync_rows` decides -// which controls to invalidate by comparing each freshly built row against the -// one on screen. A brand-new empty model per row per call therefore makes every -// row differ from *itself* on every parameter event, and the panel rewrites all -// of them. -// -// That is not merely wasteful — it breaks dragging. An operation with several -// parameters renders them through a repeater whose model is read off the -// group's head row; rewriting that row re-evaluates the repeater, rebuilding -// its items and destroying the `TouchArea` that holds the gesture. The slider -// takes the press, jumps once, then goes dead under the finger. Only -// multi-parameter operations show it, because a lone parameter has no inner -// repeater to rebuild — which is exactly how it hid: exposure and contrast drag -// perfectly while temperature and tint do not. -// -// Most rows carry neither points nor choices, so the empty case is the common -// one and it costs nothing to make it a constant. - -/// The empty points model, shared by every row that is not a curve. -fn no_points() -> slint::ModelRc { - thread_local! { - static EMPTY: slint::ModelRc = - slint::ModelRc::new(slint::VecModel::from(Vec::::new())); - } - EMPTY.with(Clone::clone) -} - -/// The empty choices model, shared by every row that is not an enum. -fn no_choices() -> slint::ModelRc { - thread_local! { - static EMPTY: slint::ModelRc = - slint::ModelRc::new(slint::VecModel::from(Vec::::new())); - } - EMPTY.with(Clone::clone) -} - -/// The choices model for one enum parameter, built once per variant list. -/// -/// Memoised for exactly the reason [`no_choices`] is shared: `ModelRc` compares -/// by *identity*, so building a fresh one each call makes the row differ from -/// itself on every parameter event. `sync_rows` would then replace the row — -/// destroying the elements built from it, including whichever `TouchArea` is -/// holding the current gesture — and the enum's own control would fight every -/// slider drag elsewhere in the panel. -/// -/// Curve rows solve the same problem the other way, by writing new values -/// through the existing model. That is not available here: a variant list is -/// fixed at compile time, so the model never needs updating and can simply be -/// the same one every time. -/// -/// Keyed on the labels rather than the slice's address, because they are -/// resolved through the UI's catalogue and two operations offering the same -/// choices should share one model. -fn choices_model(labels: &[slint::SharedString]) -> slint::ModelRc { - use std::cell::RefCell; - use std::collections::HashMap; - - thread_local! { - static CACHE: RefCell>> = - RefCell::new(HashMap::new()); - } - let key = labels.join("\u{1f}"); - CACHE.with(|cache| { - cache - .borrow_mut() - .entry(key) - .or_insert_with(|| slint::ModelRc::new(slint::VecModel::from(labels.to_vec()))) - .clone() - }) -} - -/// Whether this frontend has an implementation of `widget` **anywhere**. -/// -/// "Anywhere" is doing real work: a widget may be drawn in the panel, as the -/// tone curve is, or hosted on the canvas, as the crop is. Both count as -/// implemented, and the difference is settled afterwards by -/// [`WidgetKind::is_on_canvas`] rather than by two separate lists that could -/// disagree about the same kind. -/// -/// A kind answering `false` here is not an error — the operation's parameters -/// are ordinary scalars, so it falls back to sliders and stays fully editable -/// (ARCH §4.3a). -pub(crate) fn supported(widget: WidgetKind) -> bool { - match widget { - // Drawn in the panel. - WidgetKind::ToneCurve => true, - // Hosted on the canvas: the overlay is drawn over the photograph and - // the panel contributes `ComposePanel`, the affordance that turns it - // on. - WidgetKind::CropOverlay => true, - // TRACES: FR-DEV-3 - // Hosted on the canvas too — a click on the photograph — with the - // affordance that arms it in the group's own heading. See - // `samples_the_canvas` for why this one leaves its sliders standing - // where the crop takes them away. - WidgetKind::WhitePoint => true, - // Not implemented. Listed rather than caught by a wildcard so the next - // kind added to the core surfaces here as a compile error. - WidgetKind::ColourWheel | WidgetKind::GradientHandle | WidgetKind::BrushMask => false, - } -} - -/// TRACES: FR-DEV-3a | FR-UI-7 -/// Whether an on-canvas widget *reads* the photograph rather than replacing -/// its parameters with handles. -/// -/// **This is the distinction that stopped the picker eating its own sliders.** -/// [`WidgetKind::is_on_canvas`] says where a widget is manipulated, and the -/// panel had been treating that as also meaning "and so the panel draws -/// nothing for it". For a crop that is right: four edge fractions and an angle -/// are not controls anybody drags in a list, and the whole reason the crop is -/// on the photograph is that they are unusable anywhere else. -/// -/// An eyedropper is the other thing. It *writes* temperature and tint — they -/// remain exactly the controls a photographer reaches for afterwards, because -/// a sampled neutral is a starting point and warming a portrait past it is the -/// next move, not a mistake. Taking the sliders away to make room for the -/// picker would be trading a control for a control. -/// -/// So the panel draws the group as usual and puts the affordance that arms the -/// canvas in its heading. FR-DEV-3's "temperature/tint, **and** picker" is one -/// word doing a lot of work, and this is the word. -fn samples_the_canvas(widget: WidgetKind) -> bool { - match widget { - WidgetKind::WhitePoint => true, - // Dragged rather than sampled: the parameters *are* the handles. - WidgetKind::CropOverlay | WidgetKind::GradientHandle | WidgetKind::BrushMask => false, - // Not on the canvas at all, so nothing asks. Listed rather than - // wildcarded for the reason `supported` lists its own. - WidgetKind::ToneCurve | WidgetKind::ColourWheel => false, - } -} - -/// The panel model for a set of capabilities. -/// -/// Free-standing rather than a method, and that is the point: it needs no GPU, -/// no decoded image and no session, so the whole descriptor-to-panel path can -/// be exercised against a hand-built capability list. That is what the -/// FR-DEV-3c acceptance test asks for — an operation the frontend has never -/// heard of appearing in a generated panel — and it cannot be asserted at all -/// if generating a row requires a device. -/// -/// `#[cfg(test)]` since the panel began passing the selected curve down: the -/// session always has one to pass, and a wrapper that quietly picked the first -/// would be a second answer to a question the session already answers. -#[cfg(test)] -pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec { - rows_filtered(caps, |_| true, 0) -} - -/// The panel model for the capabilities `keep` accepts. -/// -/// **`op_index` counts over every capability, not over the kept ones.** It is -/// how a row routes back to the core, so filtering must not renumber it — a -/// row that survived a filter has to still name the operation it came from. -/// `group_head` is the opposite: a position within the *emitted* rows, because -/// the panel walks back to it through the model it was given. -/// -/// Getting that backwards is how a slider ends up driving a different -/// operation, which is the kind of fault that looks like a rendering bug. -/// -/// `curve_channel` is which subject a multi-subject widget is showing — the -/// tone curve's four curves are one plot with a selector over it. It is passed -/// in rather than read from anywhere because this function is deliberately -/// free-standing: the descriptor-to-panel path has to be exercisable against a -/// hand-built capability list with no session behind it. -pub(crate) fn rows_filtered( - caps: &[OpCapability], - keep: impl Fn(&OpCapability) -> bool, - curve_channel: usize, -) -> Vec { - let mut rows = Vec::new(); - for (op_index, op) in caps.iter().enumerate() { - if !keep(op) { - continue; - } - // Where this operation's rows begin. The panel groups by walking - // back to it, so it has to be taken before any row is pushed. - let group_head = rows.len(); - // TRACES: FR-DEV-3 - // Whether this group's heading carries the affordance that arms an - // on-canvas sampler. Set below, from what the operation asked for and - // nothing else — the panel never learns which operation it is. - let mut group_samples = false; - // An operation may ask for one widget spanning several - // parameters. Honouring it is optional — dropping this block - // renders the same parameters as ordinary sliders, and the edit - // still works — which is exactly why the hint is a hint. - if let Some(presentation) = &op.presentation { - // **The widget registry, and the only one.** - // - // `choose` walks the operation's preference list and hands back - // the first entry this frontend implements (ARCH §4.3a). A kind - // it does not implement falls through to sliders — the designed - // behaviour, not a gap, since every parameter is an individually - // addressable scalar. - if let Some(widget) = presentation.choose(supported) { - // **Yielded to the canvas, and this is what replaced naming - // framing.** - // - // This loop used to open with `if op.id == framing::ID { continue }` - // and a paragraph explaining that a crop is dragged on the - // photograph rather than typed into four boxes. All of that is - // true and none of it was this file's to know: it is a fact - // about the operation, and it now arrives as one. Any stage - // preferring an on-canvas widget is skipped here on the same - // terms, with nothing named. - // - // Skipped rather than rendered as an affordance row, because - // the affordance is `ComposePanel` — a bespoke control for a - // known stage, which is a thing the interface is entitled to - // build (ARCH §4.3a draws the line at the *generated* panel - // naming stages, not at the interface having hand-made - // widgets). - // - // TRACES: FR-DEV-3 - // **Unless the canvas is *reading* rather than driving.** An - // eyedropper writes temperature and tint and leaves them as - // the controls they were, so its group is drawn in full and - // only the affordance moves to the heading. See - // `samples_the_canvas` for the whole of that argument. - group_samples = samples_the_canvas(widget); - if widget.is_on_canvas() && !group_samples { - continue; - } - - // The `match` is exhaustive on purpose. Adding a `WidgetKind` - // to the core stops this compiling until someone has decided, - // here, whether the panel draws it. - let row = match widget { - WidgetKind::ToneCurve => { - curve_row(op_index, group_head, op, presentation, curve_channel) - } - // Canvas-hosted kinds returned above, except a - // sampler, which falls through to its own sliders; the - // rest are not implemented and reached sliders via - // `choose`. - WidgetKind::ColourWheel - | WidgetKind::CropOverlay - | WidgetKind::GradientHandle - | WidgetKind::BrushMask - | WidgetKind::WhitePoint => None, - }; - if let Some(row) = row { - rows.push(row); - continue; - } - } - } - - // Whether anything in this operation has been touched, aggregated - // before the rows are built so every row of the group can carry - // the same answer — the panel's heading is one of them and cannot - // see the others. - // - // Derived here rather than asked of the core: a group is a - // composition this side invented, so whether one is modified is - // this side's question to answer (ARCH §4.3a). - let group_modified = op.params.iter().any(|p| p.value != p.default); - let group_len = op.params.len() as i32; - - // The aspect the previous row belonged to, so a run can be told - // from its continuation. Reset per operation: two operations that - // happened to facet on the same key are still two groups. - let mut previous_aspect: Option<&str> = None; - - for param_index in presentation_order(&op.params) { - let p = &op.params[param_index]; - // Empty for every kind but `Enum`, which is what the panel - // keys on to build a segmented control rather than a slider. - let mut choices: Vec = Vec::new(); - let (kind, min, max, precision, unit) = match &p.kind { - ParamKind::Scalar { - min, - max, - unit, - precision, - .. - } => ( - "scalar", - *min, - *max, - i32::from(*precision), - unit_suffix(*unit), - ), - ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""), - // The value is a variant index, so the range is the list's - // own bounds and the precision is whole numbers. Labels are - // resolved here, against this crate's catalogue, because - // the core deals in localisation keys only (NFR-A11Y-1). - ParamKind::Enum { variants } => { - choices = variants - .iter() - .map(|v| labels::resolve(v.0).into()) - .collect(); - ("enum", 0.0, variants.len().saturating_sub(1) as f32, 0, "") - } - }; - - // A faceted parameter is named by its *subject* — the band — - // because its aspect is already written above the run it sits - // in. Unfaceted parameters keep their own label, which is - // every operation but the mixer. - // - // Except when the parameter is the operation's only one. The panel - // draws no heading over a group of one, on the argument that a - // lone control names itself — and that holds only while the - // parameter is named after what it does. Three operations declare - // a single parameter called `amount`, which is the name - // `ops/README.md` tells an author to reach for first, and they - // arrived in the panel as three consecutive sliders all labelled - // "Amount" with nothing to tell them apart. - // - // So a lone parameter is titled by its operation. That is the name - // the missing heading would have carried, and for the operations - // whose one parameter already shares the operation's name it reads - // exactly as it did before. - let param_label = match &p.facet { - Some(f) => labels::resolve(f.subject.0), - None if op.params.len() == 1 => labels::resolve(op.label.0), - None => labels::resolve(p.label.0), - }; - let aspect = p.facet.as_ref().map(|f| f.aspect.0); - let starts_facet = aspect.is_some() && aspect != previous_aspect; - previous_aspect = aspect; - - rows.push(ParamRow { - op_index: op_index as i32, - param_index: param_index as i32, - op_label: labels::resolve(op.label.0).into(), - param_label: param_label.into(), - facet_label: aspect.map(labels::resolve).unwrap_or_default().into(), - starts_facet, - // -1 rather than an `Option`, which a Slint struct cannot - // carry: 0° is red, so no value in range can stand for - // "no swatch". - swatch_hue: p.facet.as_ref().and_then(|f| f.subject_hue).unwrap_or(-1.0), - group_head: group_head as i32, - group_len, - group_modified, - group_samples, - kind: kind.into(), - value: p.value, - default_value: p.default, - minimum: min, - maximum: max, - precision, - unit: unit.into(), - // Only curve rows carry points. - points: no_points(), - // The shared empty model unless this row really has choices — - // see `no_choices` for why the identity matters. - choices: if choices.is_empty() { - no_choices() - } else { - choices_model(&choices) - }, - }); - } - } - rows -} - -/// One run of a curve widget's parameters: the points of a single curve. -/// -/// A widget may span several curves — the tone curve is one plot over a master -/// curve and three colour channels — and it says so the way the colour mixer -/// says it has twelve bands: by faceting each parameter with the *subject* it -/// acts on. Consecutive parameters sharing a subject are one curve. -struct CurveRun { - /// The subject's localisation key, or `None` where the widget's parameters - /// carry no facet at all and are therefore a single unnamed curve. - subject: Option<&'static str>, - /// Where this run's points begin in the operation's parameter list. What - /// a drag routes back through, so it must be a position in `op.params` - /// and not in the presentation's list. - base: usize, - /// How many coordinates it holds. - len: usize, -} - -/// TRACES: FR-DEV-3a -/// The curves a curve widget spans, in the order the operation declares them. -/// -/// **This is the whole of the panel's knowledge of colour channels: none.** It -/// groups by whatever subject the parameters carry, so an operation offering a -/// master curve and three channels gets a four-way selector, one offering a -/// single unfaceted curve gets no selector at all, and one that grows a fifth -/// curve tomorrow needs no change here. -/// -/// Returns `None` where the parameters do not look like point coordinates — -/// an odd count, a run that is not contiguous in the capability list — in -/// which case the caller falls back to sliders rather than drawing a widget -/// over a layout it has guessed at. -fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option> { - // Points are x/y pairs, so an odd count means the operation and this code - // disagree about the layout. - if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) { - log::warn!("{}: curve widget needs an even parameter count", op.id); - return None; - } - - let mut runs: Vec = Vec::new(); - for id in &presentation.params { - // The widget addresses points by offset from the first of its run, so - // a run has to be contiguous in the capability list. - let at = op.params.iter().position(|p| p.id == *id)?; - let subject = op.params[at].facet.as_ref().map(|f| f.subject.0); - - match runs.last_mut() { - Some(run) if run.subject == subject && run.base + run.len == at => run.len += 1, - _ => runs.push(CurveRun { - subject, - base: at, - len: 1, - }), - } - } - - if runs.iter().any(|r| !r.len.is_multiple_of(2)) { - log::warn!("{}: a curve's points are not contiguous", op.id); - return None; - } - Some(runs) -} - -/// One row standing for a whole curve. -/// -/// `channel` picks which of the widget's curves is plotted; it is clamped -/// rather than validated, because the selection is interface state that -/// outlives a change of photograph and the new image's operation may have -/// fewer curves than the old one's. -/// -/// Returns `None` if the operation's parameters do not look like point -/// coordinates, in which case the caller falls back to sliders rather than -/// rendering a broken widget. -fn curve_row( - op_index: usize, - group_head: usize, - op: &OpCapability, - presentation: &Presentation, - channel: usize, -) -> Option { - let runs = curve_runs(op, presentation)?; - let run = runs.get(channel.min(runs.len().saturating_sub(1)))?; - - let points: Vec = op.params[run.base..run.base + run.len] - .iter() - .map(|p| p.value) - .collect(); - - Some(ParamRow { - op_index: op_index as i32, - // The first point parameter *of the curve on show*; the widget offsets - // from here, so switching curve is what re-points the drag. - param_index: run.base as i32, - op_label: labels::resolve(op.label.0).into(), - param_label: String::new().into(), - // A widget spanning a whole operation is not a row in anyone's - // grid, so it heads no run and carries no swatch. - facet_label: String::new().into(), - starts_facet: false, - swatch_hue: -1.0, - group_head: group_head as i32, - // One widget standing for every parameter of the operation, so - // the group it heads is itself and nothing else. - group_len: 1, - group_modified: op.params.iter().any(|p| p.value != p.default), - // A curve is drawn, not sampled. Its own affordance is the plot. - group_samples: false, - kind: "curve".into(), - value: 0.0, - default_value: 0.0, - minimum: 0.0, - maximum: 1.0, - precision: 4, - unit: String::new().into(), - points: slint::ModelRc::new(slint::VecModel::from(points)), - // A curve is not a choice between named alternatives. The curves it - // can switch between are named on the panel rather than on the row — - // see `DevelopSession::curve_channels` for why they cannot ride here. - choices: no_choices(), - }) -} - -impl DevelopSession { - /// TRACES: FR-DEV-3 - /// The names of the curves the widget can switch between. - /// - /// Empty where there is only one, which is also the answer for a frontend - /// with no curve at all: a selector over a single choice is a row of - /// nothing. - /// - /// **Derived from the facets, so nothing here names a colour channel.** - /// The operation says its forty points are one control applied to four - /// subjects and publishes a localisation key for each; this resolves the - /// keys and hands over four words. An operation that grew a fifth curve - /// would appear here on its own. - /// - /// A panel property rather than a field on the curve's `ParamRow`, and the - /// reason is Slint's: a row's models are compared by identity, so a fresh - /// list of names built on every parameter event would make the row look - /// changed every time, and rewriting a row rebuilds the repeater item - /// underneath it — destroying the `TouchArea` holding the drag in - /// progress. The same hazard `rows`'s in-place point update exists to - /// avoid. Nothing in this list is a drag target, so up here it is safe to - /// replace wholesale, exactly as [`Self::curve_samples`] is. - pub fn curve_channels(&self) -> Vec { - for op in &self.scoped_capabilities() { - let Some(presentation) = &op.presentation else { - continue; - }; - if presentation.choose(supported) != Some(WidgetKind::ToneCurve) { - continue; - } - let Some(runs) = curve_runs(op, presentation) else { - continue; - }; - if runs.len() < 2 { - continue; - } - return runs - .iter() - .map(|r| r.subject.map(labels::resolve).unwrap_or_default()) - .collect(); - } - Vec::new() - } - - /// Which curve the widget is plotting, as an index into - /// [`Self::curve_channels`]. - pub fn curve_channel(&self) -> i32 { - self.curve_channel as i32 - } - - /// Plot a different one of the operation's curves. - /// - /// Out-of-range indices are ignored rather than clamped: the only thing - /// that can send one is a stale interface event, and quietly moving the - /// selection somewhere the user did not point is worse than doing nothing. - pub fn set_curve_channel(&mut self, index: i32) { - let Ok(index) = usize::try_from(index) else { - return; - }; - if index < self.curve_channels().len() { - self.curve_channel = index; - } - } - - /// The plotted curve's shape, sampled for drawing. - /// - /// Evaluated with `dr_pipeline`'s own spline, so the line the user drags - /// is the line the shader applies. The alternative — reading the curve - /// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a - /// polyline. - /// - /// The line drawn is the *selected* curve's own shape, not the composition - /// of it with the master. Two curves overlaid on one grid is a plot of two - /// things, and the one being dragged has to be the one whose points are - /// under the pointer. - pub fn curve_samples(&self) -> Vec { - const SAMPLES: usize = 96; - - // The selection is an index over the subjects the panel found, which - // for this operation is its channel order. Clamped rather than - // trusted: a selection made on one photograph outlives the change to - // the next. - let channel = curve::Channel::ALL[self.curve_channel.min(curve::CHANNELS - 1)]; - - let mut xs = [0.0f32; curve::POINTS]; - let mut ys = [0.0f32; curve::POINTS]; - let mut found = false; - - for cap in self.graph.capabilities() { - if cap.id != curve::ID { - continue; - } - found = true; - // By id rather than by position, so which curve is plotted is - // decided by naming it and not by arithmetic over the parameter - // list. - let value = |id| { - cap.params - .iter() - .find(|p| p.id == id) - .map_or(0.0, |p| p.value) - }; - for i in 0..curve::POINTS { - xs[i] = value(curve::coordinate(channel, i, curve::Axis::X)); - ys[i] = value(curve::coordinate(channel, i, curve::Axis::Y)); - } - } - if !found { - return Vec::new(); - } - - // Sorted the same way the operation sorts before handing points to - // the shader, or a dragged-past point would draw differently from - // how it renders. - sort_with_gap(&mut xs); - - (0..SAMPLES) - .map(|i| { - let x = i as f32 / (SAMPLES - 1) as f32; - curve::evaluate(&xs, &ys, x).clamp(0.0, 1.0) - }) - .collect() - } - - /// Return every parameter of one operation to its default. - /// - /// What both a section's reset and a curve's reset do — a curve is one - /// widget spanning all of its operation's parameters, so "reset this - /// curve" and "reset this operation" were always the same action. Nothing - /// here is curve-shaped; it walks whatever parameters the operation - /// declares. - pub fn reset_op(&mut self, op_index: i32) { - let caps = self.scoped_capabilities(); - let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else { - return; - }; - if !self.active_masks.is_empty() { - let params: Vec<_> = cap.params.iter().map(|p| (p.id, p.default)).collect(); - let id = cap.id.0; - for layer in self.active_layers_mut() { - for &(param, default) in ¶ms { - layer.set_param(id, param, default); - } - } - self.history - .record(&self.graph, Edit::Action(labels::step::RESET_OP)); - return; - } - for p in &cap.params { - self.graph.set_param(cap.id, p.id, p.default); - } - // One step, though it moved every parameter the operation has: the - // user pressed one button. - self.history - .record(&self.graph, Edit::Action(labels::step::RESET_OP)); - } - - /// Reset a curve, which is to reset its operation. - /// - /// Kept as its own name because the call site is a curve widget's own - /// double-click, and reading `reset_curve` there says why it resets ten - /// parameters at once rather than the one that was clicked. - pub fn reset_curve(&mut self, op_index: i32) { - self.reset_op(op_index); - } - - /// Apply a change from the interface. - /// - /// Indices are positions in [`Self::rows`]; the mapping back to ids stays - /// on this side of the boundary. - pub fn set_param(&mut self, op_index: i32, param_index: i32, value: f32) { - let Some((op, param)) = self.lookup(op_index, param_index) else { - log::warn!("control at ({op_index}, {param_index}) has no parameter"); - return; - }; - if !self.active_masks.is_empty() { - // Every selected layer is set to the same absolute value the - // slider now shows, not offset by however far each one already - // was from it — the slider has one position, and "apply this - // reading to all of them" is the reading a photographer gets - // from watching it move. - for layer in self.active_layers_mut() { - layer.set_param(op.0, param, value); - } - // Coalesced the same way a global drag is: a slider dragged across - // masked layers is still one gesture and must undo as one. - let edit = Edit::for_param(&self.graph, op, param); - self.history.record(&self.graph, edit); - return; - } - - self.graph.set_param(op, param, value); - let edit = Edit::for_param(&self.graph, op, param); - self.history.record(&self.graph, edit); - } - - /// Return one parameter to its default. - pub fn reset_param(&mut self, op_index: i32, param_index: i32) { - let Some((op, param)) = self.lookup(op_index, param_index) else { - return; - }; - let default = self - .graph - .capabilities() - .iter() - .find(|c| c.id == op) - .and_then(|c| c.params.iter().find(|p| p.id == param)) - .map(|p| p.default) - .unwrap_or(0.0); - self.graph.set_param(op, param, default); - self.history - .record(&self.graph, Edit::Action(labels::step::RESET_PARAM)); - } - - pub fn reset_all(&mut self) { - self.graph.reset(); - self.history - .record(&self.graph, Edit::Action(labels::step::RESET_ALL)); - } - - /// Rasterise the current mask stack, if there is one. - /// - /// Returns `None` for a stack with no active layers, which is the common - /// case and the one that must cost nothing: the adjust pass then binds its - /// own placeholder and the generated shader has no layer block to read it - /// with. - /// Render `shader` at `w`×`h` with this edit's masks bound. - /// - /// **Every path that produces pixels must come through here.** The - /// generated shader always declares the mask binding and always emits a - /// layer block for each active layer; binding the empty placeholder - /// instead multiplies every one of them by zero. That is not an error and - /// logs nothing — the local adjustments simply are not there. Exports and - /// thumbnails both did exactly that. - /// - /// The mask array is rasterised in source space at proxy size and sampled - /// through the framing map, so one array is correct at every output size: - /// a 256px thumbnail and a 24 MP export bind the same texture. - /// - /// **And the detail stage with it.** The neighbourhood operations — noise - /// reduction, capture sharpening, and the rest of FR-DEV-3's kernels — - /// cannot be fused into the single dispatch, so an edit using one composes - /// a fused pass that hands on *linear* values and a chain of passes that - /// finishes the job (see `dr_pipeline::detail`). Those two halves must be - /// composed from one graph and dispatched together, or the fused shader's - /// storage format does not match the texture bound to it; going through - /// `render_detailed` here is what makes that true of every path at once. - /// It falls through to the plain render when the chain is empty, which is - /// almost every edit, so this costs nothing to the frames that do not - /// need it. - /// - /// `space` has to be the space `shader` was composed for. It is the last - /// pass of the detail chain that performs the output transform when there - /// is one, so the two would otherwise be free to disagree about which - /// primaries the file is in — and the result would be a correctly - /// labelled file with the wrong colours in it (FR-EXP-2). - fn render_with_masks( - &mut self, - shader: &dr_pipeline::operation::ComposedShader, - w: u32, - h: u32, - space: dr_types::ColourSpace, - ) -> Result<(), String> { - let ctx = self.ctx.clone(); - self.ensure_subject_fields(&ctx); - - let masks = self - .rasterise_masks() - .then(|| self.masks.as_ref().and_then(|p| p.array())) - .flatten(); - - // The neighbourhood stage, composed at the size actually being drawn. - // - // It has to be composed *per render* rather than cached with the edit, - // because a kernel is the one thing in this pipeline that is not - // scale-free: a sharpening radius is stated in source pixels and the - // develop view renders at whatever the viewport needs (FR-DSP-1), so - // the conversion is different for the canvas, the thumbnail and the - // export. `render_scale` works the ratio out from the framing, so a - // crop and a zoom are already accounted for, and zooming to 1:1 - // restores an exact preview with no second render path to maintain. - // - // Empty for every edit with no active neighbourhood operation — which - // is almost all of them — and `render_detailed` then falls straight - // through to the single masked dispatch this used to call. - let detail = self - .graph - .compose_detail_for(self.demosaiced.size(), (w, h), space); - // Detail passes read what the colour pass wrote, so the key they are - // cached against is the colour key: moving a sharpening slider re-runs - // this stage and not the fused one (FR-DEV-3d). - let colour_key = self - .graph - .invalidation() - .through(dr_pipeline::Affects::Colour); - - self.adjust - .render_detailed(&self.demosaiced, shader, w, h, masks, &detail, colour_key) - .map(|_| ()) - .map_err(|e| e.to_string()) - } - - /// Returns whether the array is now valid for the current stack. - /// - /// Split from reading the array back because the render below needs - /// `self.adjust` mutably while holding `self.masks` immutably. Those are - /// disjoint fields and the borrow checker will allow it — but only when - /// each is reached directly rather than through a method taking `self`. - /// What the uploaded distance fields depend on. - /// - /// The instance each layer names, and the morphology that *rebuilds* a - /// field rather than offsetting it. Nothing else: see `subject_key`. - fn subject_signature(&self) -> u64 { - use dr_pipeline::mask::{MaskSource, Morphology}; - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - let mut mix = |v: u64| { - for byte in v.to_le_bytes() { - h ^= byte as u64; - h = h.wrapping_mul(0x1000_0000_01b3); - } - }; - - // TRACES: FR-DEV-19c - // The reveal is in the key because it is in the *sequence*: revealing - // a layer with no adjustment on it gives that layer a slot, which - // renumbers every field after it. Leaving it out is the bug where - // clicking a subject shows the mask of whichever layer happened to be - // beneath it. - let reveal = self.reveal(); - mix(reveal.as_ref().map_or(0, |r| { - // Every shown id, not only which are shown: a layer joining the - // shown set renumbers every slot after it, exactly as one joining - // the active set does. Colours are left out — they change what - // the shader draws, not which field it draws through. - let mut h: u64 = 1; - for l in &r.layers { - for b in l.layer.as_bytes() { - h = h.wrapping_mul(31).wrapping_add(*b as u64); - } - h = h.wrapping_mul(31).wrapping_add(0x1f); - } - h - })); - - for layer in self.graph.masks().rendered(reveal.as_ref()) { - match &layer.base().source { - MaskSource::Subject { index, .. } => { - mix(1); - mix(*index as u64); - if layer.base().morphology.is_compound() { - mix(match layer.base().morphology { - Morphology::Close => 2, - Morphology::Open => 3, - _ => 0, - }); - mix(layer.base().morph_radius.to_bits() as u64); - } - } - // Hashed by *name*, and `4` rather than `1` so a category - // named the same as an instance index could never collide with - // it. The field has to be rebuilt when either changes. - MaskSource::Category { name, .. } => { - mix(4); - for b in name.as_bytes() { - mix(*b as u64); - } - // Only the compound operations change the field itself. - if layer.base().morphology.is_compound() { - mix(match layer.base().morphology { - Morphology::Close => 2, - Morphology::Open => 3, - _ => 0, - }); - mix(layer.base().morph_radius.to_bits() as u64); - } - // The refine control changes which pixels are in the mask - // at all, so it changes the coverage the field is measured - // from — unlike a feather, which is read off a field that - // is already correct. Omitting it here is the bug where - // the slider moves and nothing happens until some other - // control happens to invalidate the cache. - mix(layer.base().refine.to_bits() as u64); - } - _ => mix(0), - } - } - h - } - - /// One layer's coverage: what the model says now, or what the sidecar - /// remembered it saying. - /// - /// **The model first, always.** It is the live answer, it is the only one - /// that can respond to the refine control, and a run in this sitting is by - /// definition newer than anything a file was holding. - /// - /// The fallback is the point of the stored raster. A photograph reopened, - /// and a batch export from the grid — which opens a session, applies a - /// version and never runs a model at all — have no segmentation to ask, so - /// before this they resolved every subject and category layer to nothing - /// and wrote out a file missing the local adjustments, with a line in the - /// log as the only sign. See [`dr_pipeline::coverage`]. - /// - /// `None` for every other source: a gradient and a brush are rasterised - /// from their own geometry and have no coverage to fetch, and the caller - /// gives them a placeholder field so the slot indices still line up. - fn layer_coverage<'a>( - &'a self, - layer: &'a dr_pipeline::mask::MaskLayer, - width: usize, - height: usize, - ) -> Option> { - use dr_pipeline::mask::MaskSource; - - let live = self - .segmentation - .as_ref() - .and_then(|seg| match &layer.base().source { - MaskSource::Category { name, .. } => { - seg.category_mask_at(name, layer.base().refine) - } - MaskSource::Subject { index, .. } => { - seg.instance_mask(*index as usize).map(Cow::Borrowed) - } - _ => None, - }); - if live.is_some() { - return live; - } - - match layer.base().source { - // Resampled where it was written against a different proxy edge; - // in the ordinary case the sizes match and this is the decode. - MaskSource::Subject { .. } | MaskSource::Category { .. } => layer - .base() - .coverage - .as_ref() - .map(|stored| Cow::Owned(stored.decode_at(width, height))), - _ => None, - } - } - - /// TRACES: FR-CAT-8 | FR-DEV-3 - /// The mask stack as it should be written to a sidecar. - /// - /// The stack the graph holds, with each model layer's coverage brought up - /// to what the segmentation now says it is. That raster is what lets the - /// *next* opening of this photograph render the layer without a model run - /// — the whole of [`dr_pipeline::coverage`]'s reason to exist. - /// - /// # Why here, and not where the field is built - /// - /// `ensure_subject_fields` is the tempting place: it is the one funnel - /// every coverage passes through, so recording it there would catch every - /// route automatically. But it runs on a *drag* — dilating a mask with a - /// compound morphology rebuilds the field every frame — and encoding a - /// megapixel raster per frame is exactly the kind of work NFR-P5 is about. - /// - /// Saving happens when the photograph stops being the open one, once, and - /// already costs a network round trip. So the encoding is done here, where - /// nothing is waiting on it, and the session's own rendering goes on - /// reading the model directly. - /// - /// Returns the stack by value rather than mutating: the caller is - /// serialising, not editing, and a graph that quietly gained a field on - /// the way past would be a mutation nobody asked for and undo would not - /// know about. - pub fn masks_for_storage(&self) -> dr_pipeline::mask::MaskStack { - use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS}; - use dr_pipeline::mask::MaskSource; - - let mut stack = self.graph.masks().clone(); - let Some(seg) = self.segmentation.as_ref() else { - // No model has run this sitting, so whatever the layers arrived - // holding is still the best answer anyone has. Handing the stack - // back untouched is what stops a photograph that was opened, - // glanced at and closed from losing the coverage its own sidecar - // gave it. - return stack; - }; - let (pw, ph) = seg.proxy_size(); - - for layer in stack.layers_mut() { - let values = match &layer.base().source { - MaskSource::Category { name, .. } => { - seg.category_mask_at(name, layer.base().refine) - } - MaskSource::Subject { index, .. } => { - seg.instance_mask(*index as usize).map(Cow::Borrowed) - } - // Nothing else has a model behind it. Left alone rather than - // cleared, so a hand-written file's key survives a round trip - // even though nothing samples it. - _ => continue, - }; - // The layer names something this run does not contain — a stale - // index, a category the scene model no longer offers. Keeping what - // was stored is right: it is a mask that was once correct, and the - // panel is already telling the user the layer is stale. - let Some(values) = values else { continue }; - - // `None` from the encoder means "will not fit in a sidecar", and - // the stored raster is then cleared rather than left standing. It - // would describe the layer at some earlier refine, and a mask of - // the wrong shape presented as authoritative is worse than the - // honest state, which is that this one needs the model run. - layer.base_mut().coverage = - Coverage::encode(&values, pw, ph, RENDERED_LEVELS).map(Arc::new); - } - - stack - } - - /// Rebuild the distance fields if anything they depend on moved. - fn ensure_subject_fields(&mut self, ctx: &GpuContext) { - let key = self.subject_signature(); - if key == self.subject_key && self.subjects.is_some() { - return; - } - - // The proxy the fields are measured in: the segmentation's own where - // one has been run, and otherwise the size the mask array is - // rasterised at. `mask_raster_size` derives that from the photograph - // rather than from a segmentation for exactly this case, and the two - // are the same number by construction — see there. - let (pw, ph) = match self.segmentation.as_ref() { - Some(seg) => seg.proxy_size(), - None => { - let (w, h) = self.mask_raster_size(); - (w as usize, h as usize) - } - }; - - // In `rendered()` order, because that is the order the rasteriser - // walks and the order it indexes these by — the revealed layer - // included, which is why the reveal is asked for here and folded into - // the key above. - let reveal = self.reveal(); - let mut fields: Vec> = Vec::new(); - for layer in self.graph.masks().rendered(reveal.as_ref()) { - let field = match self.layer_coverage(layer, pw, ph) { - Some(coverage) => { - dr_segment::Shaped::build( - &coverage, - pw, - ph, - 128, - morphology_for(layer.base().morphology), - // Radii are fractions of the shorter edge; the field - // is in proxy pixels. - layer.base().morph_radius * pw.min(ph) as f32, - ) - .distance - } - // Full size, never `unwrap_or_default`: an empty vec is a - // wrong-sized field, `SubjectMasks::upload` rejects the whole - // batch on one, and every other layer in the stack then loses - // its mask too. One stale name should cost one layer, not all - // of them. - // - // It is also the placeholder a gradient or a brush gets, so - // the slot indices line up with `active()` whatever mix of - // sources the stack holds. - None => vec![-1.0; pw * ph], - }; - fields.push(field); - } - - if fields.is_empty() { - self.subjects = None; - self.subject_key = key; - return; - } - - let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect(); - self.subjects = dr_gpu::SubjectMasks::upload(ctx, &refs, pw as u32, ph as u32) - .inspect_err(|e| log::warn!("could not upload the subject fields: {e}")) - .ok(); - self.subject_key = key; - } - - fn rasterise_masks(&mut self) -> bool { - // TRACES: FR-DEV-19c - // A stack that changes no pixel is normally not worth a pass — except - // when one of its layers is being looked at, which is exactly the - // state a fresh selection is in. Asking `rendered_count` rather than - // `is_neutral` is what makes "click a category, see its mask" work at - // all: before it, the array was never rasterised, the slice the reveal - // samples held whatever was last in it, and the answer was a blank - // photograph. - let reveal = self.reveal(); - if self.graph.masks().rendered_count(reveal.as_ref()) == 0 { - return false; - } - // **Source space, at the segmentation's proxy size** — not the - // viewport's. The generated shader samples this after the framing map, - // so a mask drawn here stays on the photograph through a zoom, a pan - // and a crop. Rasterising at viewport size, as this first did, pinned - // the mask to the screen instead: zooming slid the picture underneath - // one that stayed put. - // - // It also means the array does not reallocate when the window - // resizes, and does not need redrawing when the view moves. - let (pw, ph) = self.mask_raster_size(); - let subjects = self.subjects.as_ref(); - // TRACES: FR-DEV-10 - // A refcount, taken before the rasteriser is borrowed mutably. A range - // layer measures the photograph itself, so the pass needs the source - // as well as the stack — and it is the same texture every other pass - // reads, not a copy made for masking. - let source = self.demosaiced.clone(); - - // **Built here, not in `segment`.** A gradient needs no segmentation — - // a graduated filter over a sky never had to know what a sky is — but - // the rasteriser was only ever constructed on the way out of one, so - // adding a gradient to a photograph nobody had segmented produced an - // array that was never rasterised and a layer that drew nothing at - // all. Silently: the generated shader still emits the layer's block - // and the empty placeholder multiplies it by zero, which is the same - // failure exports and thumbnails had. - // - // The shader compile this costs is paid once, on the first frame after - // the first mask is added — a button press, not a frame anyone is - // dragging through. `is_neutral` above is what keeps it off the path - // of every photograph that has no local adjustment at all. - if self.masks.is_none() { - let ctx = self.ctx.clone(); - self.masks = dr_gpu::MaskPass::new(&ctx) - .inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}")) - .ok(); - } - let Some(pass) = self.masks.as_mut() else { - return false; - }; - // No label field: region masks were the watershed's, and nothing - // produces one any more. A stored layer that still names regions is - // skipped by the rasteriser rather than drawn wrong. - pass.render_revealing( - self.graph.masks(), - None, - subjects, - Some(source.as_ref()), - pw, - ph, - reveal.as_ref(), - ) - .inspect_err(|e| log::warn!("mask rasterisation failed: {e}")) - .is_ok() - } - - /// The size the mask array is rasterised at, in source space. - /// - /// **A property of the photograph, not of the segmentation.** The two come - /// out the same because both are the source scaled to `SEGMENT_PROXY_EDGE`, - /// and they have to: a subject layer's distance field is built at the - /// segmentation's proxy and sampled against this array, so the two sizes - /// agreeing is a requirement rather than a coincidence. Reading the size - /// *off* the segmentation is what made it look like a dependency, and made - /// a gradient — which indexes nothing — wait for a model to run. - /// - /// The aspect must be the source's either way. A gradient's geometry is - /// measured against the frame's own proportions, so a square array over a - /// 3:2 photograph would stretch every circle it drew. - fn mask_raster_size(&self) -> (u32, u32) { - if let Some(seg) = self.segmentation.as_ref() { - let (pw, ph) = seg.proxy_size(); - return (pw as u32, ph as u32); - } - let (sw, sh) = self.demosaiced.size(); - let scale = (SEGMENT_PROXY_EDGE as f32 / sw.max(sh).max(1) as f32).min(1.0); - ( - ((sw as f32 * scale) as u32).max(1), - ((sh as f32 * scale) as u32).max(1), - ) - } - - // ---------------------------------------------------------------------- - // Segmentation (S15, docs/dev/segmentation.md) - // ---------------------------------------------------------------------- - - /// This session's name, carried by any work started against it. - pub fn id(&self) -> SessionId { - self.id - } - - /// TRACES: FR-DEV-3 - /// Everything a segmentation needs, so it can be run somewhere else. - /// - /// Taking the job is cheap — two `Arc` bumps and a texture handle — and - /// nothing about the session is borrowed past the call, which is what - /// lets the window go on drawing while the answer is being found. - pub fn segmentation_job(&self) -> SegmentationJob { - SegmentationJob { - ctx: self.ctx.clone(), - source: self.demosaiced.clone(), - session: self.id, - abandon: Abandon::default(), - names: self.face_names.clone(), - exif_orientation: self.orientation, - orientation: self.graph.framing().effective_orientation(), - } - } - - /// TRACES: FR-CULL-10 | FR-DEV-3 - /// The confirmed faces in this photograph, for naming segmented regions. - /// - /// Set once when the image opens, because that is the only moment the - /// catalog and the image id are both in reach — the develop session - /// deliberately knows nothing about either, and every segmentation run - /// after this point picks the names up for free. - /// - /// Boxes are normalised to the long edge, as the catalog stores them, so - /// they survive whatever proxy size a run settles on. - pub fn set_face_names(&mut self, names: Vec) { - self.face_names = names; - } - - /// TRACES: FR-DEV-3 - /// Take on a segmentation found elsewhere. - /// - /// The caller is responsible for checking that this result was computed - /// for *this* session — see [`SegmentationJob::session`]. Nothing here can - /// tell one photograph's subjects from another's, and a mismatch is - /// silent: the masks would rasterise, the overlay would draw, and the - /// outlines would simply follow a subject that is not in the picture. - pub fn adopt_segmentation(&mut self, found: Segmented) { - // Kept rather than replaced where there is one already: a second - // segmentation of the same photograph would otherwise throw away a - // working rasteriser for an identical one. - self.masks = self.masks.take().or(found.masks); - - // The fields themselves are built per *layer*, on demand — there are - // none yet, and building one per detected object would transform - // several megapixels for masks the user may never make. - self.segmentation = Some(found.seg); - self.subjects = None; - self.subject_key = 0; - } - - /// TRACES: FR-DEV-3 - /// Everything a refine pass needs for one already-detected instance, so - /// it can be run somewhere else — see [`Self::segmentation_job`] for why - /// the split exists. - /// - /// `None` where there is nothing to refine: no segmentation yet, or an - /// index the panel offered a button for a moment ago but the segmentation - /// underneath has since changed. - pub fn refine_job(&self, index: usize) -> Option { - let seg = self.segmentation.as_ref()?; - let instance = seg.instances().get(index)?; - Some(RefineJob { - ctx: self.ctx.clone(), - source: self.demosaiced.clone(), - session: self.id, - abandon: Abandon::default(), - index, - class_name: instance.class_name.clone(), - bbox: instance.bbox, - proxy: seg.proxy_size(), - orientation: self.graph.framing().effective_orientation(), - }) - } - - /// Take on a refined instance found elsewhere. - /// - /// Same caution as [`Self::adopt_segmentation`]: the caller checks the - /// job's session before handing back its answer, not this. Every mask - /// layer pointing at this instance's index reads it fresh next redraw — - /// `subject_key` is reset because the pixels changed under an index - /// `subject_signature` has no way to know changed, unlike a morphology - /// slider it does track. - pub fn adopt_refined(&mut self, refined: RefinedInstance) { - if let Some(seg) = self.segmentation.as_mut() { - seg.replace_instance(refined.index, refined.summary); - } - self.subjects = None; - self.subject_key = 0; - } - - /// The mask layer's original detection index, for the refine button — - /// `None` for a layer that is not a subject at all (a gradient has no - /// instance to re-detect). - pub fn subject_instance_index(&self, id: &str) -> Option { - match &self.graph.masks().get(id)?.base().source { - MaskSource::Subject { index, .. } => Some(*index as usize), - _ => None, - } - } - - /// Find the subjects and take them on, blocking until both are done. - /// - /// Test-only, and deliberately: a session-shaped blocking call is exactly - /// the shape that put two thirds of a second on the UI thread in the first - /// place, and leaving it public would invite the next caller to reach for - /// it. A test has nothing else to be doing. - #[cfg(test)] - fn segment(&mut self, options: &segmentation::Options) -> Result<(), String> { - if let Some(found) = self.segmentation_job().run(options)? { - self.adopt_segmentation(found); - } - Ok(()) - } - - pub fn has_segmentation(&self) -> bool { - self.segmentation.is_some() - } - - /// The subjects the model recognised, as `(label, confidence)`. - /// - /// Confidence is shown rather than hidden because the detector is offered - /// as a shortcut, not as an authority: a 0.42 "dog" is worth listing and - /// worth flagging, and a list that presented it identically to a 0.95 one - /// would make the tool look wrong when the guess was merely weak. - pub fn detected_subjects(&self) -> Vec<(String, f32)> { - self.segmentation - .as_ref() - .map(|s| { - s.instances() - .iter() - .map(|i| (i.class_name.to_string(), i.score)) - .collect() - }) - .unwrap_or_default() - } - - // ---------------------------------------------------------------------- - // Seeing the mask (FR-DEV-19c) - // ---------------------------------------------------------------------- - - /// TRACES: FR-DEV-19c - /// What the canvas should draw over the photograph, if anything. - /// - /// Every layer whose eye is open, in stack order, each in its colour — - /// and `None` when no eye is, so the rasteriser and the composer can - /// take the path they always took. - /// - /// Rebuilt per call rather than kept in step with the stack, because it - /// is a walk over at most eight layers — cheaper than the invalidation a - /// cached copy would need every time a layer is added, removed, renamed - /// or reordered. - pub(crate) fn reveal(&self) -> Option { - use dr_pipeline::mask::{Reveal, RevealedLayer}; - // Only while masking. The eyes are per layer and outlive the mode, - // so a photographer coming back finds the layers they were looking - // at still lit — but a tint is a way of looking at a *mask*, and - // outside Local there is no mask being looked at. Without this the - // sky stayed red through Repair and back in Photo, a mode that had - // been left leaving its overlay behind (ui-navigation.md D-N1). - if !self.show_overlay { - return None; - } - let layers: Vec = self - .graph - .masks() - .layers() - .iter() - .filter_map(|l| { - let view = self.mask_views.get(&l.id).filter(|v| v.shown)?; - Some(RevealedLayer { - layer: l.id.clone(), - colour: MASK_COLOURS[view.colour % MASK_COLOURS.len()], - }) - }) - .collect(); - if layers.is_empty() { - return None; - } - Some(Reveal { - layers, - style: self.reveal_style, - }) - } - - /// How shown masks are drawn, as an index into - /// [`dr_pipeline::mask::RevealStyle::ALL`]. - /// - /// An index because the panel offers it as a strip of chips and an index - /// is what a strip of chips reports. The enum stays the thing that is - /// stored, so a fourth style is a variant and a label rather than a number - /// two files have to agree on. - pub fn mask_view_style(&self) -> usize { - dr_pipeline::mask::RevealStyle::ALL - .iter() - .position(|&a| a == self.reveal_style) - .unwrap_or(0) - } - - /// Choose how shown masks are drawn. - /// - /// Takes no history step and marks nothing dirty: this is how the - /// photograph is being *looked at*, not an edit to it. - pub fn set_mask_view_style(&mut self, style: usize) { - if let Some(&s) = dr_pipeline::mask::RevealStyle::ALL.get(style) { - self.reveal_style = s; - } - } - - /// Whether this layer's mask is drawn over the photograph. - pub fn mask_shown(&self, id: &str) -> bool { - self.mask_views.get(id).is_some_and(|v| v.shown) - } - - /// Open or close one layer's eye. - pub fn set_mask_shown(&mut self, id: &str, shown: bool) { - let colour = self.next_mask_colour(); - self.mask_views - .entry(id.to_string()) - .or_insert(MaskView { - shown: false, - colour, - }) - .shown = shown; - } - - /// Whether any mask at all is being shown. - /// - /// What the region overlay asks before drawing: two overlays that mean - /// different things, on top of each other, is neither. - pub fn any_mask_shown(&self) -> bool { - self.reveal().is_some() - } - - /// Which of [`MASK_COLOURS`] this layer is shown in. - pub fn mask_colour(&self, id: &str) -> usize { - self.mask_views - .get(id) - .map_or(0, |v| v.colour % MASK_COLOURS.len()) - } - - /// Give this layer a colour from [`MASK_COLOURS`]. - /// - /// Choosing a colour is asking to see it: a swatch pressed on a layer - /// whose eye was closed opens the eye, because nothing else the press - /// could mean would change a pixel. - pub fn set_mask_colour(&mut self, id: &str, colour: usize) { - let colour = colour % MASK_COLOURS.len(); - self.mask_views - .entry(id.to_string()) - .and_modify(|v| { - v.colour = colour; - v.shown = true; - }) - .or_insert(MaskView { - shown: true, - colour, - }); - } - - /// The colour the next layer to be shown should take: the first not - /// already in use, or round the palette again once all are. - /// - /// So that two masks made one after the other come up in two colours - /// without anyone having to choose — which is the case that matters, - /// since "how do these two meet" is the question two masks are shown to - /// answer. - fn next_mask_colour(&self) -> usize { - let used: Vec = self.mask_views.values().map(|v| v.colour).collect(); - (0..MASK_COLOURS.len()) - .find(|c| !used.contains(c)) - .unwrap_or(self.mask_views.len() % MASK_COLOURS.len()) - } - - /// TRACES: FR-DEV-19c - /// Show the mask of a layer that has just been made. - /// - /// **Making a mask is asking what it selected**, and for a subject or a - /// category that question has no other answer: the model's outline is not - /// derivable from anything on screen, the layer carries no adjustment yet, - /// and the list it was chosen from says "architecture 23%" and nothing - /// about *which* 23%. So the mask appears with the layer rather than - /// waiting to be asked for a second time — its eye open, in the next - /// colour nothing else is using. - /// - /// Only this layer's eye. Every other layer keeps whatever the - /// photographer set it to, which is the trap `Masking.overlay-hidden` - /// documents: an automatic reveal that undoes a switch somebody turned - /// off is worse than none. - fn show_new_mask(&mut self, id: &str) { - let colour = self.next_mask_colour(); - self.mask_views.insert( - id.to_string(), - MaskView { - shown: true, - colour, - }, - ); - } - - // ---------------------------------------------------------------------- - // The region overlay - // ---------------------------------------------------------------------- - - pub fn overlay_enabled(&self) -> bool { - self.show_overlay - } - - pub fn set_overlay(&mut self, on: bool) { - self.show_overlay = on; - } - - /// The part of the overlay the view is currently showing, in overlay - /// pixels: `(x, y, width, height)`. - /// - /// The overlay is a **source-space** picture, and the canvas beside it - /// shows whatever the crop, the zoom and the pan selected out of that same - /// space. Drawn whole, it stays the size of the frame while the photograph - /// moves underneath — which is exactly the fault this exists to fix. - /// - /// Reported as a clip rectangle rather than resampled here: the compositor - /// crops and scales a texture for nothing, where doing it on the CPU would - /// mean rebuilding a megapixel image on every frame of a drag. - /// - /// **Known gap.** A quarter turn or a flip permutes the axes, and a clip - /// rectangle cannot express that — the straightening angle is handled - /// alongside this, but a quarter-turned frame shows the overlay unturned. - /// Fixing it properly means running the overlay through the same shader - /// prologue the image goes through, which is the right answer and a larger - /// one than this. - pub fn overlay_clip(&self) -> (i32, i32, i32, i32) { - let Some(seg) = self.segmentation.as_ref() else { - return (0, 0, 0, 0); - }; - // **Shown pixels, matching `overlay_image`.** The crop and the - // viewport are fractions of the photograph as the user sees it — the - // prologue maps an output pixel through `crop_rect` *before* it - // unturns the frame — so measuring them against the sensor's width - // and height puts the clip on the wrong axis the moment the two - // differ. That is the same confusion as the overlay itself had, one - // layer down, and it is silent for exactly the images where it is - // wrong: a landscape frame has nothing to notice. - let (sw, sh) = seg.proxy_size(); - let (w, h) = self - .graph - .framing() - .effective_orientation() - .oriented_size(sw as u32, sh as u32); - let rect = self.graph.framing().visible_rect(); - - // Rounded outward, so half a pixel of rounding never shows as a strip - // of missing overlay along an edge. - let x = (rect.x * w as f32).floor().max(0.0) as i32; - let y = (rect.y * h as f32).floor().max(0.0) as i32; - let right = ((rect.x + rect.width) * w as f32).ceil().min(w as f32) as i32; - let bottom = ((rect.y + rect.height) * h as f32).ceil().min(h as f32) as i32; - - (x, y, (right - x).max(1), (bottom - y).max(1)) - } - - /// TRACES: FR-DEV-3 - /// A false-coloured picture of what a click can select, for the canvas. - /// - /// Returned as a CPU image rather than a texture, and deliberately: it is - /// regenerated only when the segmentation changes, it is proxy-sized - /// rather than viewport-sized, and the compositor scales and clips it for - /// free. Putting it on the GPU would buy nothing and add a second texture - /// to keep in step with the view. - /// - /// `None` when the overlay is off or nothing has been segmented, so the - /// caller can bind this straight to an image source. - pub fn overlay_image(&self) -> Option { - if !self.show_overlay { - return None; - } - let (rgba, w, h) = self.segmentation.as_ref()?.overlay_rgba(); - - // TRACES: FR-DEV-3h - // **Turned the right way up before it is drawn.** Instance masks live - // in sensor space, because the generated shader samples them after - // the framing map (`uv_src`) — but this is not sampled by that shader. - // It is a flat image handed to the compositor to lay over a - // photograph that *has* been through the framing map, so it has to - // arrive in the same space the photograph is in. - // - // Without this the outlines are drawn in the sensor's orientation over - // an upright picture: on a portrait frame the colour sits nowhere near - // the subject, which reads as the detector having failed rather than - // as the overlay being turned. Nothing announces it, and it is - // invisible on landscape frames, which is most of them. - let (rgba, w, h) = self - .graph - .framing() - .effective_orientation() - .into_shown(&rgba, w, h, 4); - - let buffer = slint::SharedPixelBuffer::::clone_from_slice(&rgba, w, h); - Some(slint::Image::from_rgba8(buffer)) - } - - // ---------------------------------------------------------------------- - // Mask layers - // ---------------------------------------------------------------------- - - /// The layers, as `(id, name, enabled, is_active_selection)`. - pub fn mask_layers(&self) -> Vec<(String, String, bool, bool)> { - self.graph - .masks() - .layers() - .iter() - .map(|l| { - ( - l.id.clone(), - l.display_name().to_string(), - l.enabled, - self.active_masks.iter().any(|a| a == &l.id), - ) - }) - .collect() - } - - /// What kind of mask a layer is — "regions", "linear", "radial". - pub fn mask_kind(&self, id: &str) -> &'static str { - self.part_of(id).map_or("", |p| p.source.kind()) - } - - pub fn mask_inverted(&self, id: &str) -> bool { - self.graph.masks().get(id).is_some_and(|l| l.invert) - } - - pub fn mask_opacity(&self, id: &str) -> f32 { - self.graph.masks().get(id).map_or(1.0, |l| l.opacity) - } - - /// Whether a layer has any adjustment on it yet. - /// - /// Distinct from `is_active`, which also asks whether the layer is enabled - /// and visible. The panel wants specifically "you have made a selection - /// and not yet done anything with it", because that state looks identical - /// to a broken mask and is the most likely thing a first-time user hits. - pub fn mask_is_adjusted(&self, id: &str) -> bool { - self.graph - .masks() - .get(id) - .is_some_and(|l| l.active_ops().next().is_some()) - } - - /// The panel's representative selection — see [`Self::active_layer`] for - /// what "representative" means once more than one layer is selected. - pub fn active_mask(&self) -> Option<&str> { - self.active_masks.first().map(String::as_str) - } - - /// Every selected layer's id, in selection order. - pub fn active_masks(&self) -> &[String] { - &self.active_masks - } - - /// TRACES: FR-DEV-3 | FR-UI-3 - /// The selected gradient's handles, in fractions of the shown image. - /// - /// Empty unless **exactly one** gradient layer is selected. Dragging a - /// shared handle for several gradients at once has no single geometry to - /// move — each one's centre, angle and extent differ — so multi-select - /// simply offers no handles rather than moving one layer's shape while - /// silently leaving the others behind. - /// - /// Recomputed on every redraw rather than cached, because the answer - /// changes with the *view* and not only with the mask: a pan moves every - /// handle and touches no geometry. Four handles through an affine map is - /// not work worth caching, and a cache keyed on the wrong thing is how a - /// handle comes to sit where the mask used to be. - pub fn gradient_handles(&self) -> Vec { - if self.active_masks.len() != 1 { - return Vec::new(); - } - let Some(layer) = self.active_layer() else { - return Vec::new(); - }; - let (sw, sh) = self.demosaiced.size(); - crate::gradient::handles(&layer.base().source, self.graph.framing(), (sw, sh)) - } - - /// Drag one handle of the selected gradient, from `press` to `now`, both - /// in fractions of the shown image. - /// - /// `origin` is the geometry the gesture started from — see - /// [`crate::gradient::drag`] for why a drag is applied to that rather than - /// accumulated. Returns it, so the caller can hold it for the rest of the - /// gesture; `None` when there is no gradient selected to drag. - pub fn drag_gradient_handle( - &mut self, - role: crate::HandleRole, - origin: Option<&MaskSource>, - press: (f32, f32), - now: (f32, f32), - ) -> Option { - if self.active_masks.len() != 1 { - return None; - } - let (sw, sh) = self.demosaiced.size(); - let framing = *self.graph.framing(); - let id = self.active_masks.first()?.clone(); - let start = match origin { - Some(s) => s.clone(), - None => self.graph.masks().get(&id)?.base().source.clone(), - }; - - let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh)); - self.graph.masks_mut().get_mut(&id)?.base_mut().source = moved; - // **Nothing recorded here.** A drag delivers a pointer event a frame, - // and a history step per frame would make undo walk a gesture back - // pixel by pixel. `Edit` coalesces by operation id and a mask's shape - // is not an operation, so there is no key to coalesce under — the - // honest answer is to record once, on release. - Some(start) - } - - /// A handle drag finished: one history step for the whole gesture. - /// - /// Called on the pointer's release rather than on each move, which is what - /// makes a drag one decision in the undo stack however many frames it took. - pub fn commit_gradient_drag(&mut self) { - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_MOVED)); - } - - // --- editing a mask by hand (FR-DEV-19) -------------------------------- - - /// TRACES: FR-DEV-19a - /// Which part of `id` the edge controls act on. - /// - /// The selected part when this is the layer being edited, and the base - /// otherwise — because a panel that is not showing a layer's parts has not - /// offered anybody a way to choose one, and answering with a part they - /// cannot see would make the same slider mean different things depending - /// on what was selected a moment ago. - fn shaped_part(&self, id: &str) -> usize { - match self.active_masks.as_slice() { - [only] if only == id => self.active_part, - _ => 0, - } - } - - /// The part of `id` the edge controls read. - fn part_of(&self, id: &str) -> Option<&dr_pipeline::mask::MaskPart> { - let index = self.shaped_part(id); - self.graph.masks().get(id)?.part(index) - } - - /// The same, to write through. - fn part_of_mut(&mut self, id: &str) -> Option<&mut dr_pipeline::mask::MaskPart> { - let index = self.shaped_part(id); - self.graph.masks_mut().get_mut(id)?.part_mut(index) - } - - /// Which part of the selected layer the tools point at. - pub fn active_part(&self) -> usize { - self.active_part - } - - /// Point the tools at one part, or at the base when the index is past the - /// end — which is what a part being removed under the selection leaves. - pub fn set_active_part(&mut self, index: usize) { - let parts = self.active_layer().map_or(1, |l| l.parts().len()); - self.active_part = if index < parts { index } else { 0 }; - } - - /// The parts of a layer: id, what to call it, and how it joins. - /// - /// The join of the first is meaningless — there is nothing before it to - /// join to — and the panel shows it as the selection the layer *is* - /// rather than as a row with a chip that does nothing. - pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize, bool)> { - let Some(layer) = self.graph.masks().get(id) else { - return Vec::new(); - }; - layer - .parts() - .iter() - .map(|p| { - let join = dr_pipeline::mask::Join::ALL - .iter() - .position(|&j| j == p.join) - .unwrap_or(0); - (p.id.clone(), p.source.kind().to_string(), join, p.hidden) - }) - .collect() - } - - /// TRACES: FR-DEV-19a - /// Leave one part out of the build, or put it back. An edit, and one - /// history step, for the same reason the layer's own switch is: the part - /// really is out until it is switched back. - pub fn set_mask_part_hidden(&mut self, id: &str, index: usize, hidden: bool) { - let Some(layer) = self.graph.masks_mut().get_mut(id) else { - return; - }; - let Some(part) = layer.part_mut(index) else { - return; - }; - if part.hidden == hidden { - return; - } - part.hidden = hidden; - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_PART_TOGGLED)); - } - - /// TRACES: FR-DEV-19a - /// Join a fresh painted part to a layer, returning its index. - /// - /// Painted, because that is the correction a photographer reaches for - /// first and the only source that needs nothing found for it. The other - /// sources arrive when a part can carry its own distance field. - pub fn add_mask_part(&mut self, id: &str, join: usize) -> Option { - use dr_pipeline::mask::{Join, MaskPart}; - let &join = Join::ALL.get(join)?; - let layer = self.graph.masks_mut().get_mut(id)?; - let part_id = layer.next_part_id(); - if !layer.push_part(MaskPart::painted(part_id, join)) { - return None; - } - let index = layer.parts().len() - 1; - self.active_part = index; - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_PART_ADDED)); - Some(index) - } - - /// Take a part back out of a layer. The base is not removable — removing - /// the selection a layer *is* is removing the layer. - pub fn remove_mask_part(&mut self, id: &str, index: usize) { - let Some(layer) = self.graph.masks_mut().get_mut(id) else { - return; - }; - if layer.remove_part(index).is_none() { - return; - } - self.set_active_part(self.active_part.min(index.saturating_sub(1))); - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_PART_REMOVED)); - } - - /// Change how a part joins: added to the mask, or taken out of it. - pub fn set_mask_part_join(&mut self, id: &str, index: usize, join: usize) { - use dr_pipeline::mask::Join; - let Some(&join) = Join::ALL.get(join) else { - return; - }; - let Some(layer) = self.graph.masks_mut().get_mut(id) else { - return; - }; - // The first part joins nothing, so saying how it joins would be a - // control that moves and changes no pixel. - if index == 0 { - return; - } - let Some(part) = layer.part_mut(index) else { - return; - }; - part.join = join; - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_JOINED)); - } - - /// The brush: radius, hardness, flow. - pub fn brush(&self) -> (f32, f32, f32) { - self.brush - } - - /// Set the brush. Radius is a fraction of the frame's shorter edge, so it - /// means the same thing on the phone and on the desktop and at any zoom. - pub fn set_brush(&mut self, radius: f32, hardness: f32, flow: f32) { - self.brush = ( - radius.clamp(0.002, 0.5), - hardness.clamp(0.0, 1.0), - flow.clamp(0.01, 1.0), - ); - } - - /// How many stroke points the selected layer may still record. - /// - /// Asked by the panel so that a mask approaching its budget can say so - /// before a gesture is refused mid-stroke — which is the moment the - /// refusal is least explicable. - pub fn mask_room(&self) -> usize { - self.active_layer().map_or(0, |l| l.room()) - } - - /// TRACES: FR-DEV-19b - /// Begin a stroke at a point in fractions of the shown image. - /// - /// **Paints into the active part, or joins one if that part cannot hold a - /// stroke.** Pressing Paint on a mask the model made is the ordinary way - /// this is reached, and it must not answer with a refusal explaining that - /// a subject is not a brush: the correction the photographer is about to - /// make *is* a new part, so it is made. - /// - /// Returns whether a stroke was started. `false` means the layer is full - /// or there is nothing selected, and the caller should not send moves. - pub fn begin_mask_stroke(&mut self, x: f32, y: f32, erase: bool) -> bool { - let Some(id) = self.active_masks.first().cloned() else { - return false; - }; - if self.active_masks.len() != 1 { - // Several layers share the slider drags; a stroke has one target - // and guessing which of three it is would be worse than refusing. - return false; - } - - let paintable = self - .graph - .masks() - .get(&id) - .and_then(|l| l.part(self.active_part)) - .is_some_and(|p| matches!(p.source, MaskSource::Brush { .. })); - if !paintable { - use dr_pipeline::mask::Join; - let join = if erase { Join::Subtract } else { Join::Union }; - let position = Join::ALL.iter().position(|&j| j == join).unwrap_or(0); - if self.add_mask_part(&id, position).is_none() { - return false; - } - } - - let part = self.active_part; - let (radius, hardness, flow) = self.brush; - // An erase stroke inside a part that subtracts would take away from - // what the part removes, which reads backwards. In a subtracting part - // the brush's two modes are already the right way round. - let subtracting = self - .graph - .masks() - .get(&id) - .and_then(|l| l.part(part)) - .is_some_and(|p| p.join == dr_pipeline::mask::Join::Subtract); - let erase = erase && !subtracting; - - let Some(layer) = self.graph.masks_mut().get_mut(&id) else { - return false; - }; - if !layer.begin_stroke(part, erase, radius, hardness, flow) { - return false; - } - self.painting = Some((id, part)); - self.extend_mask_stroke(x, y); - true - } - - /// Carry the stroke to another point, in fractions of the shown image. - /// - /// The point is mapped into normalised **source** coordinates on the way - /// in, through the same framing map the shader applies — so a stroke stays - /// on the thing it was painted on through a zoom, a pan, a crop and a - /// straighten, and lands in an export at any size where it was drawn. - pub fn extend_mask_stroke(&mut self, x: f32, y: f32) { - let Some((id, part)) = self.painting.clone() else { - return; - }; - let (sw, sh) = self.demosaiced.size(); - let (sx, sy) = self.graph.framing().source_at((x, y), sw, sh); - if let Some(layer) = self.graph.masks_mut().get_mut(&id) { - layer.extend_stroke(part, sx, sy); - } - } - - /// Finish the stroke: one history step for the whole gesture. - /// - /// One step, on release, for the reason a handle drag records once — a - /// stroke is a decision, and undo that walked it back dab by dab would - /// make taking a mark back cost as many presses as making it did. - pub fn end_mask_stroke(&mut self) { - let Some((id, part)) = self.painting.take() else { - return; - }; - let erased = self - .graph - .masks() - .get(&id) - .and_then(|l| l.part(part)) - .and_then(|p| p.strokes().last()) - .is_some_and(|s| s.erase); - if let Some(layer) = self.graph.masks_mut().get_mut(&id) { - layer.end_stroke(part); - } - let step = if erased { - labels::step::MASK_ERASED - } else { - labels::step::MASK_PAINTED - }; - self.history.record(&self.graph, Edit::Action(step)); - } - - /// Abandon a stroke that turned out to be something else — a pinch, or a - /// gesture the window cancelled. Nothing is recorded, because nothing - /// happened as far as the photographer is concerned. - pub fn cancel_mask_stroke(&mut self) { - let Some((id, part)) = self.painting.take() else { - return; - }; - if let Some(layer) = self.graph.masks_mut().get_mut(&id) { - layer.drop_last_stroke(part); - } - } - - // --- repairs (FR-DEV-8) ------------------------------------------------ - - /// TRACES: FR-DEV-8 - /// Cover what is at `(x, y)`, in fractions of the shown image. - /// - /// The click arrives in *output* coordinates — where the photograph - /// currently sits on screen — and a repair is stored against the - /// photograph, so it goes through `Framing::source_at`: the same map the - /// shader applies, run backwards. Anything less would put the repair where - /// the pointer was rather than where the mark is, and the two agree only at - /// fit-to-window with no crop. - /// - /// Returns the new repair's id, or `None` when the set is full. Selecting - /// it is deliberate: the control that changes its size is in the column, - /// and a photographer who has just placed a spot too small should find that - /// control already pointed at it. - pub fn place_spot(&mut self, x: f32, y: f32) -> Option { - let (sw, sh) = self.demosaiced.size(); - let centre = self.graph.framing().source_at((x, y), sw, sh); - // Outside the photograph entirely — the letterbox margin, or a drag - // that ended off the edge. Placing a repair there would put a disc - // somewhere the user cannot see and cannot pick up again. - if !(0.0..=1.0).contains(¢re.0) || !(0.0..=1.0).contains(¢re.1) { - return None; - } - - let aspect = sw.max(1) as f32 / sh.max(1) as f32; - let radius = dr_pipeline::spot::DEFAULT_RADIUS; - let offset = dr_pipeline::Spot::default_offset(centre, radius, aspect); - - let id = self - .graph - .spots_mut() - .place(dr_pipeline::Spot::new(centre, offset, radius))?; - self.selected_spot = Some(id.clone()); - self.history - .record(&self.graph, Edit::Action(labels::step::SPOT_PLACED)); - Some(id) - } - - /// TRACES: FR-DEV-8 | FR-UI-3 - /// Every repair as a circle on the shown image, plus the source circle of - /// the selected one. - /// - /// # Why only the selected repair shows its source - /// - /// A dusty sky carries a dozen repairs. Two dozen circles with nothing - /// saying which source belongs to which disc is not more information, it is - /// less — and there is no room on a phone for a connector between each - /// pair. The selection is what disambiguates them, which is also why a - /// press on a repair selects it before the drag begins. - /// - /// Recomputed per redraw rather than cached, for the reason - /// [`Self::gradient_handles`] gives: the answer changes with the *view*, - /// and a pan moves every circle while touching no edit. - pub fn spot_handles(&self) -> Vec { - let (sw, sh) = self.demosaiced.size(); - let framing = self.graph.framing(); - let aspect = sw.max(1) as f32 / sh.max(1) as f32; - // The shown image's own shape, which is not the source's once the frame - // has been cropped or turned. A radius is reported against its height, - // so this is what converts the x half of the mapped offset. - let (ow, oh) = self.graph.output_size(sw, sh); - let shown_aspect = ow.max(1) as f32 / oh.max(1) as f32; - - let mut handles = Vec::new(); - for spot in self.graph.spots().spots() { - let selected = self.selected_spot.as_deref() == Some(spot.id.as_str()); - let centre = framing.output_at(spot.centre, sw, sh); - - // The radius, mapped rather than scaled: a point one radius above - // the centre goes through the same map, and the distance between - // the two answers is the radius as drawn. The x half is multiplied - // by the shown aspect because the two axes are normalised by - // different lengths, and a circle measured in mixed units is an - // ellipse. - let rim = framing.output_at((spot.centre.0, spot.centre.1 + spot.radius), sw, sh); - let radius = ((rim.0 - centre.0) * shown_aspect).hypot(rim.1 - centre.1); - - handles.push(crate::SpotHandle { - id: spot.id.clone().into(), - role: crate::SpotRole::Destination, - x: centre.0, - y: centre.1, - radius, - selected, - enabled: spot.enabled, - }); - - if selected { - let source = framing.output_at(spot.source(aspect), sw, sh); - handles.push(crate::SpotHandle { - id: spot.id.clone().into(), - role: crate::SpotRole::Source, - x: source.0, - y: source.1, - radius, - selected: true, - enabled: spot.enabled, - }); - } - } - handles - } - - /// TRACES: FR-DEV-8 - /// Drag one circle of one repair, from `press` to `now`, both in fractions - /// of the shown image. - /// - /// Dragging the disc moves the whole repair and carries its source along — - /// what a photographer means by nudging a spot. Dragging the source moves - /// the source alone, which is the override FR-DEV-8 asks for over the - /// automatic placement. - /// - /// `origin` is the repair as it stood when the gesture began; the caller - /// holds it for the duration and hands it back, so a drag is applied to - /// that rather than accumulated frame by frame — the rule - /// [`Self::drag_gradient_handle`] states, for the same reasons. - pub fn drag_spot( - &mut self, - id: &str, - role: crate::SpotRole, - origin: Option<&dr_pipeline::Spot>, - press: (f32, f32), - now: (f32, f32), - ) -> Option { - let (sw, sh) = self.demosaiced.size(); - let framing = *self.graph.framing(); - let aspect = sw.max(1) as f32 / sh.max(1) as f32; - - let start = match origin { - Some(spot) => spot.clone(), - None => self.graph.spots().get(id)?.clone(), - }; - - // The displacement in source coordinates. Affine, so a movement is a - // movement: the map may be run on the two endpoints and subtracted, - // which is what makes a drag on a rotated photograph move the repair in - // the direction the finger went. - let from = framing.source_at(press, sw, sh); - let to = framing.source_at(now, sw, sh); - let moved = (to.0 - from.0, to.1 - from.1); - - let spot = self.graph.spots_mut().get_mut(id)?; - match role { - crate::SpotRole::Destination => { - spot.set_centre((start.centre.0 + moved.0, start.centre.1 + moved.1)); - } - // In frame units, because that is what an offset is stored in — and - // the x half of a normalised displacement is short by the aspect. - crate::SpotRole::Source => { - spot.set_offset((start.offset.0 + moved.0 * aspect, start.offset.1 + moved.1)); - } - } - // Nothing recorded here: a drag delivers a pointer event a frame, and - // one history step apiece would make undo walk the gesture back pixel - // by pixel. Recorded once, on release. - Some(start) - } - - /// A repair's drag finished: one history step for the whole gesture. - pub fn commit_spot_drag(&mut self) { - self.history - .record(&self.graph, Edit::Action(labels::step::SPOT_MOVED)); - } - - /// Which repair the column is describing. - pub fn selected_spot(&self) -> Option<&dr_pipeline::Spot> { - let id = self.selected_spot.as_deref()?; - self.graph.spots().get(id) - } - - pub fn selected_spot_id(&self) -> Option<&str> { - self.selected_spot.as_deref() - } - - /// Choose a repair, or `None` to describe none. - /// - /// An id the graph no longer holds selects nothing rather than being kept: - /// the circle that offered it is stale by the time the press lands, and a - /// selection pointing at a deleted repair would leave the column describing - /// something that is not on the photograph. - pub fn select_spot(&mut self, id: Option<&str>) { - self.selected_spot = id - .filter(|id| self.graph.spots().get(id).is_some()) - .map(str::to_string); - } - - /// TRACES: FR-DEV-8 - /// Take a repair off the photograph, returning whether one went. - pub fn remove_spot(&mut self, id: &str) -> bool { - if self.graph.spots_mut().remove(id).is_none() { - return false; - } - if self.selected_spot.as_deref() == Some(id) { - self.selected_spot = None; - } - self.history - .record(&self.graph, Edit::Action(labels::step::SPOT_REMOVED)); - true - } - - /// TRACES: FR-DEV-8 - /// Change one of the selected repair's settings. - /// - /// Recorded as a named [`Edit::Action`] rather than under a parameter key: - /// a repair is not an operation and has no `OpId` to name or coalesce by, - /// so a slider drag over it records a step per movement unless the caller - /// debounces. `SliderRow` fires once per completed gesture, - /// which is what makes that acceptable here and is why this is the one - /// panel in the application built from that row rather than from a live - /// track. - pub fn set_selected_spot(&mut self, change: F) -> bool - where - F: FnOnce(&mut dr_pipeline::Spot), - { - let Some(id) = self.selected_spot.clone() else { - return false; - }; - let Some(spot) = self.graph.spots_mut().get_mut(&id) else { - return false; - }; - change(spot); - self.history - .record(&self.graph, Edit::Action(labels::step::SPOT)); - true - } - - /// How many repairs this photograph carries. - pub fn spot_count(&self) -> usize { - self.graph.spots().len() - } - - /// The selected layer's mask rule, for a caller that has to remember what - /// a gesture started from. - pub fn active_mask_source(&self) -> Option { - self.active_layer().map(|l| l.base().source.clone()) - } - - /// Select exactly one layer for editing, or `None` to return the panel to - /// the global chain. Replaces whatever was selected before, including a - /// multi-selection — the ordinary, unmodified click. - /// Selecting a layer points the tools at its base again. - /// - /// Without this, selecting a two-part mask after a three-part one would - /// leave the brush aimed at a part that is not there — or, worse, at a - /// different part than the one highlighted in the panel. - pub fn set_active_mask(&mut self, id: Option<&str>) { - self.active_part = 0; - self.active_masks = id - .filter(|id| self.graph.masks().get(id).is_some()) - .map(|id| vec![id.to_string()]) - .unwrap_or_default(); - } - - /// Add or remove one layer from the selection, keeping the rest — the - /// modifier-click that builds a multi-selection. - /// - /// A layer id the graph no longer has is dropped rather than toggled in: - /// the row that offered it is stale by the time the click lands, and - /// selecting a ghost would make every subsequent batched edit silently - /// skip it (`active_layers_mut` filters by membership, not existence). - pub fn toggle_active_mask(&mut self, id: &str) { - if self.graph.masks().get(id).is_none() { - return; - } - match self.active_masks.iter().position(|a| a == id) { - Some(i) => { - self.active_masks.remove(i); - } - None => self.active_masks.push(id.to_string()), - } - } - - /// Mask the object under a normalised image point. - /// - /// Clicking the photograph is how a local adjustment begins, so this - /// creates the layer — requiring "add layer" first would be a step with no - /// decision in it. - /// - /// Returns the layer that now holds the selection. - pub fn select_region_at(&mut self, x: f32, y: f32) -> Option { - let index = self.segmentation.as_ref()?.instance_at(x, y)?; - self.add_subject_mask(index) - } - - /// Add a layer covering one photographic category. - /// - /// The counterpart to [`Self::add_subject_mask`], and it takes a *name* - /// rather than an index for the reason `MaskSource::Category` stores one: - /// the descriptor grouping ADE20K's classes is editable, so an index would - /// silently repoint every stored layer the first time a category was - /// added to it. - /// - /// The layer is named after the category, because "sky" is a better name - /// for a layer than "Mask 3" and the user can rename it anyway. - pub fn add_category_mask(&mut self, name: &str) -> Option { - use dr_pipeline::mask::MaskSource; - - let seg = self.segmentation.as_ref()?; - let signature = seg.signature(); - // Refuse a category this run did not produce rather than creating a - // layer that renders empty: an empty mask looks like a broken - // adjustment, where a button that does nothing at least says so. - seg.category_mask(name)?; - - let id = self.graph.masks().next_id(); - let mut layer = MaskLayer::new( - id.clone(), - MaskSource::Category { - signature, - name: name.to_string(), - }, - ); - layer.name = name.to_string(); - // Refined from the start, by as much as this photograph will bear. - // - // A category's edges are twenty proxy pixels wide before this runs, so - // the unrefined mask is the wrong default for the common case — a - // photographer adding a sky mask wants the sky, not the sky plus every - // chimney in it. Zero is still one drag away, and it is exactly the - // model's own weighting when they get there. - // - // **Asked of the frame rather than taken from a constant**, and that - // is the correction rather than a refinement of the idea. The constant - // was `STRICTNESS_DEFAULT`, fitted on a synthetic sky; on real - // photographs it removes three quarters to all of `architecture`, - // `ground` and `vegetation`, so clicking a category produced an empty - // mask — the failure that reads as the feature not working at all, - // because the adjustment moves and no pixel changes. The measurements - // are on `dr_segment::Refinement::gentle`, which is what picks the - // number now. - // - // Set here rather than in `MaskLayer::new` because the number belongs - // to `dr_segment` and `dr-pipeline` does not depend on it — see - // `dr_pipeline::mask::MAX_REFINE`. - layer.base_mut().refine = self - .segmentation - .as_ref() - .map_or(dr_segment::STRICTNESS_OFF, |seg| { - seg.category_default_refine(name) - }); - if !self.graph.masks_mut().push(layer) { - return None; - } - self.active_masks = vec![id.clone()]; - self.show_new_mask(&id); - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); - Some(id) - } - - /// What the scene model found in this frame, largest category first. - /// - /// Empty when no scene model was available, which is an ordinary state — - /// see `segmentation::scene_categories`. - pub fn categories(&self) -> &[segmentation::CategorySummary] { - self.segmentation.as_ref().map_or(&[], |s| s.categories()) - } - - /// Add a layer selecting one detected subject. - /// - /// The instance's own coverage is the mask, rather than the watershed - /// regions it overlaps. Snapping to regions was the original design and - /// it is not currently worth doing: the hierarchy those ids index into - /// collapses on a photograph (docs/dev/segmentation.md §15), so snapping - /// would trade the model's approximately-right outline for a - /// confidently-wrong one. - pub fn add_subject_mask(&mut self, index: usize) -> Option { - let seg = self.segmentation.as_ref()?; - let instance = seg.instances().get(index)?; - let signature = seg.signature(); - let name = instance.class_name.to_string(); - let score = instance.score; - - let id = self.graph.masks().next_id(); - let mut layer = MaskLayer::new( - id.clone(), - MaskSource::Subject { - signature, - index: index as u32, - class: name.clone(), - score, - }, - ); - layer.name = name; - if !self.graph.masks_mut().push(layer) { - return None; - } - self.active_masks = vec![id.clone()]; - self.show_new_mask(&id); - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); - Some(id) - } - - /// Add a gradient layer, which needs no segmentation. - pub fn add_gradient_mask(&mut self, radial: bool) -> Option { - let id = self.graph.masks().next_id(); - let source = if radial { - MaskSource::Radial { - centre: (0.5, 0.5), - radii: (0.35, 0.35), - angle: 0.0, - feather: 0.5, - } - } else { - MaskSource::Linear { - centre: (0.5, 0.5), - angle: std::f32::consts::FRAC_PI_2, - width: 0.3, - } - }; - if !self - .graph - .masks_mut() - .push(MaskLayer::new(id.clone(), source)) - { - return None; - } - self.active_masks = vec![id.clone()]; - self.show_new_mask(&id); - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); - Some(id) - } - - /// TRACES: FR-DEV-19b - /// Add a mask that is nothing but hand-painted, and select it. - /// - /// **The one route to a brush that starts from nothing.** Everything else - /// in this file makes a layer out of a selection — a gradient, a band, a - /// subject, a category — and painting was reachable only by making one of - /// those first and then joining a painted part to it. So the answer to - /// "brush a correction onto this corner of the sky" was "add a radial - /// gradient you do not want, then paint into it", which is not an answer. - /// - /// The layer covers nothing until a stroke lands in it, which is exactly - /// what [`dr_pipeline::mask::MaskPart::covers`] is about: it is not active, - /// it costs no slice, and an invert on it would not take the adjustment - /// global. What the panel shows meanwhile is a row with the tools armed - /// over it — see `masks_ui`, which arms them. - pub fn add_brush_mask(&mut self) -> Option { - let id = self.graph.masks().next_id(); - if !self - .graph - .masks_mut() - .push(MaskLayer::new(id.clone(), MaskSource::brush())) - { - return None; - } - self.active_masks = vec![id.clone()]; - self.active_part = 0; - self.show_new_mask(&id); - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); - Some(id) - } - - /// TRACES: FR-DEV-10 - /// Add a mask that selects by a range of the photograph's own values. - /// - /// Needs no segmentation, like a gradient, and for a stronger reason: a - /// band is a question about the picture rather than about what is in it. - /// `chromatic` picks the colour range over the brightness one. - pub fn add_range_mask(&mut self, chromatic: bool) -> Option { - let id = self.graph.masks().next_id(); - let source = if chromatic { - MaskSource::skin_tones() - } else { - MaskSource::highlights() - }; - if !self - .graph - .masks_mut() - .push(MaskLayer::new(id.clone(), source)) - { - return None; - } - self.active_masks = vec![id.clone()]; - self.show_new_mask(&id); - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); - Some(id) - } - - /// TRACES: FR-DEV-10 - /// Move a range layer's band — its two bounds and the fade at each edge. - /// - /// All three at once, because they are one control: dragging the lower - /// bound past the upper swaps them (see `MaskSource::luminance_range`), - /// and a setter per field would have to decide that question three times - /// with only a third of the answer each time. - /// - /// A no-op on a layer that is not a range, rather than a panic: the panel - /// asks first, and a callback that arrives against a layer the user has - /// since replaced is ordinary rather than a fault. - pub fn set_mask_band(&mut self, id: &str, lo: f32, hi: f32, softness: f32) { - let Some(layer) = self.part_of_mut(id) else { - return; - }; - // Built into a temporary first: the arc is read out of the layer and - // the whole source is written back over it, and the two cannot be the - // same statement. - let rebuilt = match &layer.source { - MaskSource::Luminance { .. } => MaskSource::luminance_range(lo, hi, softness), - MaskSource::Colour { hue, hue_width, .. } => { - MaskSource::colour_range(*hue, *hue_width, lo, hi, softness) - } - _ => return, - }; - layer.source = rebuilt; - // `Control`, not `Action`: a band is dragged, and a drag is one - // decision however many values it passes through. - self.history - .record(&self.graph, Edit::Control(labels::step::MASK_RANGE)); - } - - /// TRACES: FR-DEV-10 - /// Move a colour range's arc — its centre hue and its half-width. - pub fn set_mask_hue(&mut self, id: &str, hue: f32, width: f32) { - let Some(layer) = self.part_of_mut(id) else { - return; - }; - // A temporary, for the reason `set_mask_band` gives. - let rebuilt = match &layer.source { - MaskSource::Colour { - chroma_lo, - chroma_hi, - softness, - .. - } => MaskSource::colour_range(hue, width, *chroma_lo, *chroma_hi, *softness), - // Only a colour range has an arc. A brightness one reaching here - // is a stale callback against a layer the user has replaced, - // which is ordinary rather than a fault. - _ => return, - }; - layer.source = rebuilt; - self.history - .record(&self.graph, Edit::Control(labels::step::MASK_RANGE)); - } - - /// TRACES: FR-DEV-10 - /// Whether the band controls apply to this layer. - pub fn mask_is_ranged(&self, id: &str) -> bool { - self.part_of(id).is_some_and(|p| p.source.is_range()) - } - - /// TRACES: FR-DEV-10 - /// Whether this layer's band is over colour rather than over brightness. - pub fn mask_is_chromatic(&self, id: &str) -> bool { - self.part_of(id) - .is_some_and(|p| matches!(p.source, MaskSource::Colour { .. })) - } - - /// TRACES: FR-DEV-10 - /// This layer's band: lower bound, upper bound, softness. - /// - /// Tone positions for a luminance layer and chroma for a colour one — - /// the same two questions asked of different measurements, which is why - /// one panel control drives both. Zeroes for a layer that has no band, - /// which the panel never draws. - pub fn mask_band(&self, id: &str) -> (f32, f32, f32) { - match self.part_of(id).map(|p| &p.source) { - Some(MaskSource::Luminance { lo, hi, softness }) => (*lo, *hi, *softness), - Some(MaskSource::Colour { - chroma_lo, - chroma_hi, - softness, - .. - }) => (*chroma_lo, *chroma_hi, *softness), - _ => (0.0, 0.0, 0.0), - } - } - - /// TRACES: FR-DEV-10 - /// A colour range's arc: centre hue and half-width, both in turns. - pub fn mask_hue(&self, id: &str) -> (f32, f32) { - match self.part_of(id).map(|p| &p.source) { - Some(MaskSource::Colour { hue, hue_width, .. }) => (*hue, *hue_width), - _ => (0.0, 0.0), - } - } - - pub fn remove_mask(&mut self, id: &str) { - if self.graph.masks_mut().remove(id).is_some() { - self.active_masks.retain(|a| a != id); - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_REMOVED)); - } - } - - pub fn set_mask_enabled(&mut self, id: &str, enabled: bool) { - if let Some(layer) = self.graph.masks_mut().get_mut(id) { - layer.enabled = enabled; - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_TOGGLED)); - } - } - - pub fn set_mask_invert(&mut self, id: &str, invert: bool) { - if let Some(layer) = self.graph.masks_mut().get_mut(id) { - layer.invert = invert; - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_INVERTED)); - } - } - - /// Edge transition half-width, in fractions of the shorter edge. - pub fn set_mask_feather(&mut self, id: &str, feather: f32) { - if let Some(part) = self.part_of_mut(id) { - part.feather = feather.clamp(0.0, 1.0); - self.history - .record(&self.graph, Edit::Control(labels::step::MASK_FEATHER)); - } - } - - /// How strictly this layer's category is cut back to the pixels whose - /// colour agrees with it. - /// - /// Unlike the feather, this changes the mask's *shape*, so the distance - /// field has to be rebuilt — the same class of cost as a close or an open - /// (`dr_segment::Morphology::needs_recompute`), and the reason it is in - /// `subject_signature`. It still runs no model: the evidence was fitted - /// during segmentation and this is a smoothstep over it. - pub fn set_mask_refine(&mut self, id: &str, refine: f32) { - if let Some(part) = self.part_of_mut(id) { - part.refine = refine.clamp(0.0, dr_pipeline::mask::MAX_REFINE); - self.history - .record(&self.graph, Edit::Control(labels::step::MASK_REFINE)); - } - } - - pub fn set_mask_falloff(&mut self, id: &str, index: usize) { - use dr_pipeline::mask::Falloff; - let Some(&falloff) = Falloff::ALL.get(index) else { - return; - }; - if let Some(part) = self.part_of_mut(id) { - part.falloff = falloff; - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_FALLOFF)); - } - } - - pub fn set_mask_morphology(&mut self, id: &str, index: usize) { - use dr_pipeline::mask::Morphology; - let Some(&morphology) = Morphology::ALL.get(index) else { - return; - }; - if let Some(part) = self.part_of_mut(id) { - part.morphology = morphology; - // Picking an operation with no amount set would appear to do - // nothing, and the user would reasonably conclude it is broken. - if morphology != Morphology::None && part.morph_radius <= 0.0 { - part.morph_radius = 0.006; - } - self.history - .record(&self.graph, Edit::Action(labels::step::MASK_MORPHOLOGY)); - } - } - - pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) { - if let Some(part) = self.part_of_mut(id) { - part.morph_radius = radius.clamp(0.0, 1.0); - self.history - .record(&self.graph, Edit::Control(labels::step::MASK_MORPH)); - } - } - - /// Which falloff a layer uses, as an index into `Falloff::ALL`. - pub fn mask_falloff(&self, id: &str) -> usize { - use dr_pipeline::mask::Falloff; - self.part_of(id).map_or(0, |p| { - Falloff::ALL - .iter() - .position(|&f| f == p.falloff) - .unwrap_or(0) - }) - } - - pub fn mask_morphology(&self, id: &str) -> usize { - use dr_pipeline::mask::Morphology; - self.part_of(id).map_or(0, |p| { - Morphology::ALL - .iter() - .position(|&m| m == p.morphology) - .unwrap_or(0) - }) - } - - pub fn mask_feather(&self, id: &str) -> f32 { - self.part_of(id).map_or(0.0, |p| p.feather) - } - - pub fn mask_morph_radius(&self, id: &str) -> f32 { - self.part_of(id).map_or(0.0, |p| p.morph_radius) - } - - pub fn mask_refine(&self, id: &str) -> f32 { - self.part_of(id).map_or(0.0, |p| p.refine) - } - - /// Whether this layer has a refinement to act on. - /// - /// Two conditions, and both are needed. The source must be a category — - /// nothing else has a colour model fitted for it — and the segmentation - /// must actually have fitted one, which it cannot for a category that is - /// everywhere thinner than the model's own resolution or that fills the - /// whole frame. - /// - /// Asked by the panel before it draws the slider, because a control that - /// moves and does nothing is worse than an absent one. - pub fn mask_is_refinable(&self, id: &str) -> bool { - use dr_pipeline::mask::MaskSource; - let Some(layer) = self.graph.masks().get(id) else { - return false; - }; - let index = self.shaped_part(id); - let Some(part) = layer.part(index) else { - return false; - }; - let MaskSource::Category { name, .. } = &part.source else { - return false; - }; - self.segmentation - .as_ref() - .is_some_and(|seg| seg.category_is_refinable(name)) - } - - /// Whether the edge controls apply to this layer. - /// - /// Only sources that go through the distance field. A gradient carries its - /// own falloff in its geometry, so offering a second one would be two - /// controls fighting over the same edge. - /// - /// A range (FR-DEV-10) is excluded on stronger grounds than the gradient. - /// Feather, falloff and morphology are every one of them a function of the - /// *signed distance from a boundary*, and a range mask has no boundary: it - /// is a weighting over the photograph's values, soft everywhere the values - /// are, sharp everywhere they are. There is no outline to grow, shrink or - /// cross. Its edge is the softness of its own band, which is in the band's - /// units rather than in pixels — see `MaskSource::Luminance`. - pub fn mask_is_shapeable(&self, id: &str) -> bool { - use dr_pipeline::mask::MaskSource; - self.part_of(id).is_some_and(|p| { - matches!( - p.source, - MaskSource::Subject { .. } - | MaskSource::Category { .. } - | MaskSource::Regions { .. } - ) - }) - } - - pub fn set_mask_opacity(&mut self, id: &str, opacity: f32) { - if let Some(layer) = self.graph.masks_mut().get_mut(id) { - layer.opacity = opacity.clamp(0.0, 1.0); - // `Op` rather than `Discrete`: opacity is dragged, and a drag is - // one decision however many values it passes through. `Discrete` - // would put every intermediate position on the undo stack. - self.history - .record(&self.graph, Edit::Control(labels::step::MASK_OPACITY)); - } - } - - /// Whether a layer's region ids belong to a segmentation other than the - /// one currently loaded — a mask restored from a sidecar written under - /// different tuning. - pub fn mask_is_stale(&self, id: &str) -> bool { - let Some(layer) = self.graph.masks().get(id) else { - return false; - }; - match self.segmentation.as_ref() { - Some(seg) => layer.is_stale(seg.signature()), - // Nothing loaded to compare against, and nothing to report: the - // layer renders from the raster its sidecar stored (see - // `layer_coverage`). It was already not *stale* before that — the - // word invites the user to recompute a selection that is fine — - // and now it is not unrenderable either. - None => false, - } - } - - fn lookup(&self, op_index: i32, param_index: i32) -> Option<(OpId, ParamId)> { - // Rows are emitted in capability order, so the flat index is the sum - // of preceding parameter counts. Taken from whichever scope `rows` - // last described — the indices the interface is holding are positions - // in *that* list, and reading the global chain while a layer is - // selected would map a slider onto a different operation. - // The *unfiltered* scoped list, because `op_index` counts over every - // capability — see `rows_filtered`. Indexing a filtered list here is - // how a slider would drive the wrong operation once a tab is chosen. - let caps = self.scoped_capabilities(); - let op = caps.get(usize::try_from(op_index).ok()?)?; - let param = op.params.get(usize::try_from(param_index).ok()?)?; - Some((op.id, param.id)) - } - - /// TRACES: FR-DSP-8 - /// Encode the canvas for a different display from now on. - /// - /// Returns whether anything changed, so a caller polling for window moves - /// can redraw only when the answer is genuinely different — the poll runs - /// far more often than a monitor is changed, and a redraw per poll would - /// undo the point of rendering on demand. - /// - /// Nothing is invalidated here and nothing needs to be. The next - /// [`Self::render`] composes against the new space, the pipeline cache - /// distinguishes the two shaders by the structure hash the space enters, - /// and the mask array — rasterised in source space, sampled through the - /// framing — is unaffected because a colour space is not a geometry. - pub fn set_display_space(&mut self, space: dr_types::ColourSpace) -> bool { - let changed = self.display_space != space; - self.display_space = space; - changed - } - - /// The space the canvas is currently being encoded into. - pub fn display_space(&self) -> dr_types::ColourSpace { - self.display_space - } - - /// TRACES: FR-DSP-1 | AC-8 - /// Render at the requested display size and hand back a Slint image. - /// - /// Renders at *viewport* resolution rather than sensor resolution, which - /// is what keeps slider interaction inside the frame budget on a 24 MP - /// file (FR-DSP-1). - /// - /// **The image is the texture, not a copy of it.** This used to end in a - /// `read_output` into a `SharedPixelBuffer` — the GPU→CPU→GPU round-trip - /// ARCH §6.1 forbids and AC-8 asserts against, measured at ~7 ms at 4K - /// against a 0.28 ms compute pass. Spike S1 replaced it with - /// `slint::Image::try_from`, which wraps the texture where it already is. - /// The `clone` below is a refcount on the wgpu handle, not on the pixels. - /// - /// This only works because the compositor is drawing with the same device - /// the pass wrote with; see `shared_gpu` in the crate root for how that is - /// arranged, and note that nothing here can detect it having gone wrong — - /// a texture from a foreign device is a runtime fault on a real screen, - /// which is why the arrangement is made once at startup and never again. - pub fn render(&mut self, width: u32, height: u32) -> Result { - // Fit the render to the viewport while preserving aspect, so the - // pass does no work on pixels the view will letterbox away. - // - // Fitted against the *framed* size, not the sensor's: a crop changes - // the aspect ratio, and fitting the uncropped shape would letterbox - // to the wrong box and render the crop squashed. - let (sw, sh) = self.demosaiced.size(); - let (fw, fh) = self.graph.output_size(sw, sh); - let (w, h) = fit(fw, fh, width.max(1), height.max(1)); - - // TRACES: FR-DSP-8 | FR-DSP-6 - // **Composed for the display that is showing this canvas**, not for - // sRGB. This is the whole of FR-DSP-8's second half arriving at the - // pipeline: a display change is a *recomposition* and nothing more, - // because the output space was always a parameter of composition and - // always entered the structure hash. Moving the window to a P3 panel - // therefore costs one shader compile and no pipeline change at all. - let space = self.display_space; - // TRACES: FR-DEV-19c - // **The one composition that may show a mask.** Every other caller of - // the graph — `render_the_file`, the thumbnail — goes through - // `compose_for`, which cannot ask for a reveal, so no exported file - // can carry one; `sample_as_shot` composes no operations at all. - let shader = self.graph.compose_revealing(space, self.reveal().as_ref()); - - // Rasterise the masks first: the shader addresses array slices by - // index, so the array has to describe *this* stack before it is bound. - // - // The same `space` to both, necessarily: where a detail stage exists - // it is the *last* pass that performs the output transform, and two - // halves composed for different spaces would encode the frame twice - // or not at all. - self.render_with_masks(&shader, w, h, space)?; - let texture = self.adjust.output().ok_or("nothing was rendered")?; - - // The import is fallible on format and usage only, and both are fixed - // in `AdjustPass`'s texture descriptor — so a failure here is a - // descriptor that drifted, not anything the caller did. Say that, - // rather than surfacing "InvalidUsage" to a photographer. - #[cfg(not(target_os = "android"))] - { - slint::Image::try_from(texture.clone()) - .map_err(|e| format!("the render target is not importable by the compositor: {e}")) - } - - // Android draws with Skia over OpenGL and cannot sample a - // `wgpu::Texture`, so the frame comes back through memory. See - // `crate::shared_gpu`'s Android arm for why that is the trade on this - // platform. The pass still runs on the GPU; only this last hop does not. - #[cfg(target_os = "android")] - { - let _ = texture; - let (rgba, w, h) = self - .adjust - .export_pixels() - .map_err(|e| format!("reading the rendered frame back: {e}"))?; - let mut buf = slint::SharedPixelBuffer::::new(w, h); - let wanted = (w as usize) * (h as usize) * 4; - let src = &rgba[..wanted.min(rgba.len())]; - buf.make_mut_bytes()[..src.len()].copy_from_slice(src); - Ok(slint::Image::from_rgba8(buf)) - } - } - - /// TRACES: FR-DSP-7 - /// Count the frame that is currently on the canvas. - /// - /// **Reads the frame [`Self::render`] last produced rather than rendering - /// its own.** The histogram has to describe what the photographer is - /// looking at, and rendering a second time to count it would both cost a - /// second pass and open the possibility of the two disagreeing. - /// - /// That the frame is the *displayed* one has two consequences worth being - /// explicit about. It is in the output colour space, which is what - /// FR-DSP-7 asks for — the levels counted are the levels the display will - /// show, so a clipped bin means a highlight that is actually gone rather - /// than one the transform might still recover. Since FR-DSP-8 that is the - /// space of *this display* rather than sRGB, which makes the reading more - /// truthful and not less: a highlight that survives on a wide-gamut panel - /// and clips on the laptop's screen genuinely is two different facts, and - /// the histogram now reports whichever one the photographer is looking at. And when the view is zoomed - /// or cropped it describes the visible region, not the whole file: a - /// photographer inspecting a highlight at 4× is asking about *that* - /// highlight, and a histogram of the parts of the frame off screen would - /// be answering a question nobody asked. - /// - /// `None` where nothing has been rendered yet, or where the device could - /// not build the reduction. - pub fn histogram(&self) -> Option { - let pass = self.histogram.as_ref()?; - let frame = self.adjust.output()?; - pass.compute(frame) - .inspect_err(|e| log::warn!("histogram failed: {e}")) - .ok() - } - - /// TRACES: FR-CULL-3 - /// Whether there is sensor data behind this session at all. - /// - /// False for the JPEG path, where [`DemosaicedImage::from_rgba8`] built - /// the source from an already-rendered image. There is no white level in - /// such a file and so no scale to measure headroom against: the honest - /// answer for one is that the raw instrument has nothing to say, which is - /// a different statement from a device that could not build the pass, and - /// the panel says the two differently. - pub fn has_sensor_data(&self) -> bool { - !self.demosaiced.is_non_linear() - } - - /// TRACES: FR-CULL-3 - /// Count the sensor data this photograph was demosaiced from. - /// - /// **This is the other histogram, not a variant of the one above**, and - /// the two answer questions that a culling decision needs kept apart. - /// [`Self::histogram`] counts the frame on the canvas, after white - /// balance, the camera matrix, the base curve, the tone curve and the - /// output transform: a clipped bin there is a highlight that is gone as - /// the image currently stands. This counts the demosaiced scene-linear - /// texture, before any of that, on an axis of stops below sensor - /// saturation — so a clipped bin here is a highlight that is gone *in the - /// file*, and no edit will bring it back. FR-CULL-3 exists because the - /// tools that offer the second reading do not develop, and the ones that - /// develop offer only the first — "no shipping tool combines both". - /// - /// **It describes the whole frame, not the visible region**, which is the - /// opposite of what [`Self::histogram`] does and deliberate. A crop and a - /// zoom change what is on screen; neither changes what the sensor - /// recorded, and the question this answers — how much latitude does this - /// exposure have — is asked of the capture rather than of the view. - /// - /// Computed once and cached, for the reason `raw_counts` gives. - /// - /// `None` where the file carries no sensor data, or where the device could - /// not build the reduction. The caller distinguishes those with - /// [`Self::has_sensor_data`]. - pub fn raw_histogram(&mut self) -> Option { - if self.raw_counts.is_none() { - if !self.has_sensor_data() { - return None; - } - // Scoped so the shared borrow of the pass and of the source ends - // before the cache is written, rather than relying on the reader - // to see that the two field paths are disjoint. - let counted = { - let pass = self.raw_histogram.as_ref()?; - pass.compute(self.demosaiced.texture()) - .inspect_err(|e| log::warn!("the raw histogram failed: {e}")) - .ok() - }; - self.raw_counts = counted; - } - self.raw_counts.clone() - } - - /// TRACES: FR-CULL-3 - /// Whether this device could build the focus-peaking overlay. - /// - /// Asked by the interface so that it can say the overlay is unavailable - /// rather than offer a switch that does nothing. The same courtesy the - /// histogram is not paid, and should be: a control that silently does - /// nothing is worse than one that is visibly absent. - pub fn peaking_available(&self) -> bool { - self.peak.is_some() - } - - /// TRACES: FR-CULL-3 - /// What the overlay is set to, or `None` when it is off. - pub fn peaking(&self) -> Option { - self.peaking - } - - /// TRACES: FR-CULL-3 - /// Switch the overlay on with these settings, or off. - /// - /// Asking for peaking on a device that could not build the pass leaves it - /// off, so that [`Self::peaking`] never claims something is being drawn - /// that is not. Switching off drops the overlay textures rather than - /// merely stopping drawing them: a resident overlay from the last frame is - /// one interface bug away from being laid over the next photograph. - pub fn set_peaking(&mut self, settings: Option) { - self.peaking = settings.filter(|_| self.peak.is_some()); - if self.peaking.is_none() { - if let Some(pass) = self.peak.as_mut() { - pass.clear(); - } - } - } - - /// TRACES: FR-CULL-3 | NFR-P14 - /// Mark the in-focus regions of the frame that is currently on the canvas. - /// - /// **Reads the frame [`Self::render`] last produced**, exactly as - /// [`Self::histogram`] does and for the same reason: the overlay has to - /// describe what the photographer is looking at, and rendering a second - /// time to measure it would cost a pass and admit the possibility of the - /// two disagreeing about the picture. - /// - /// That the frame is the displayed one is what makes the marks land where - /// the eye is. It is at viewport resolution, cropped and zoomed as the - /// view is, and — the point of FR-CULL-3 — descended from sensor data - /// through the demosaic rather than from the camera's embedded JPEG, whose - /// in-body sharpening this would otherwise be measuring at least as much - /// as the lens. - /// - /// **Call this only after a settled render.** See - /// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame - /// cannot be measured for sharpness. - /// - /// `None` where nothing has been rendered, where peaking is off, or where - /// the device could not build the pass. - pub fn focus_overlay(&mut self) -> Option { - let settings = self.peaking?; - // Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`, - // and holding a shared borrow of `self.adjust` across the mutable - // borrow of `self.peak` would cost a `Self { .. }` destructure to say - // something the clone says in one word. - let frame = self.adjust.output()?.clone(); - let pass = self.peak.as_mut()?; - let overlay = pass - .render(&frame, settings) - .inspect_err(|e| log::warn!("focus peaking failed: {e}")) - .ok()? - .clone(); - - #[cfg(not(target_os = "android"))] - { - // A layer over the canvas rather than a tint in it, so nothing - // here reaches the histogram or an export — see `FocusPeakPass` - // for the whole of that argument. - slint::Image::try_from(overlay) - .inspect_err(|e| log::warn!("the focus overlay is not importable: {e}")) - .ok() - } - - // Android draws with Skia over OpenGL and cannot sample a - // `wgpu::Texture`, so the overlay follows the frame it belongs to back - // through memory (technical-debt.md TD-1). The measurement still - // happens on the GPU; only this last hop does not. - #[cfg(target_os = "android")] - { - let _ = overlay; - let (rgba, w, h) = pass - .read_overlay() - .inspect_err(|e| log::warn!("reading the focus overlay back: {e}")) - .ok()?; - let mut buf = slint::SharedPixelBuffer::::new(w, h); - let wanted = (w as usize) * (h as usize) * 4; - let src = &rgba[..wanted.min(rgba.len())]; - buf.make_mut_bytes()[..src.len()].copy_from_slice(src); - Some(slint::Image::from_rgba8(buf)) - } - } - - /// Render the *whole* frame for the crop overlay to be drawn over. - /// - /// Crop mode cannot use [`Self::render`]: that applies the crop, so the - /// area being cropped away would not be on screen and there would be - /// nothing to drag the handles across. This renders as though the crop - /// were full, and the interface draws the rect and greys the surround. - /// - /// Zoom is suspended too. Panning a zoomed view while also dragging crop - /// handles is two conflicting meanings for one drag, and the handles are - /// placed against the whole frame in any case. - /// - /// Returns the image together with the size it was rendered at, since the - /// overlay has to place its rect against exactly those pixels. - pub fn render_uncropped( - &mut self, - width: u32, - height: u32, - ) -> Result<(slint::Image, u32, u32), String> { - let saved_crop = self.graph.crop(); - let saved_view = self.graph.framing().view(); - - self.graph.set_crop(CropRect::default()); - self.graph.framing_mut().set_view(CropRect::default()); - - let result = self.render(width, height); - - // Restored whatever happened: leaving the graph cropped-to-full on a - // render error would silently discard the user's crop. - self.graph.set_crop(saved_crop); - self.graph.framing_mut().set_view(saved_view); - - let image = result?; - let (sw, sh) = self.demosaiced.size(); - // The uncropped frame still turns with the quarter turns, so the - // overlay's box comes from the framing rather than the sensor. - let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh); - let (rw, rh) = fit(fw, fh, width.max(1), height.max(1)); - Ok((image, rw, rh)) - } - - /// TRACES: FR-DEV-7 - /// Render the photograph as the file has it, with every adjustment off. - /// - /// **What a held comparison shows, and it is not a history state.** - /// FR-DEV-7 asks for the current edit against the unedited original, and - /// the only thing the interface had was [`Self::go_to_history`] — which - /// *changes* the edit rather than previewing against it. A photographer - /// who suspects they have overcooked a frame therefore had to undo, look, - /// and redo, which puts two real steps on the stack at exactly the moment - /// they are least sure of what they are doing. - /// - /// This puts none there. It borrows the graph for the length of one - /// render and hands it back: the same shape [`Self::render_uncropped`] - /// uses for the crop and [`Self::render_the_file`] uses for the zoom, and - /// for the same reason — the graph is the one description of the - /// photograph, so a second rendering of it is a suspension rather than a - /// copy. Nothing is recorded, nothing is marked modified, and a caller - /// asking whether the image differs from its defaults gets the same - /// answer before and after. - /// - /// **The framing stays on**, and that is a decision rather than an - /// oversight. A held before/after is a question about tone and colour — - /// "have I pushed this too far" — and re-cropping the canvas under the - /// photographer's thumb would move the very detail they are comparing. - /// It would also make the view meaningless: the zoom is a rectangle of the - /// *framed* image, so dropping the crop at 4× would show a different part - /// of the photograph rather than the same part unedited. What the crop - /// took away is compared in Compose, which already shows the whole frame. - /// - /// Restored whatever happens, for the reason `render_uncropped` restores - /// its crop: leaving the graph stripped after a failed render would - /// discard the entire edit, silently. - pub fn render_original(&mut self, width: u32, height: u32) -> Result { - let saved = self.graph.state(); - self.strip_adjustments(); - let rendered = self.render(width, height); - let debt = self.graph.set_state(&saved); - self.pay_film_debt(&debt); - rendered - } - - /// TRACES: FR-DEV-7 - /// Take everything off the graph except the shape of the frame. - /// - /// Four removals rather than [`EditGraph::reset`], which would take the - /// framing with it. The empty preset applied at - /// [`Scope::adjustments`] — the scope that is defined as "all of it but - /// the crop" — clears every parameter the photographer can move, and the - /// three things that are not parameters go by hand: local adjustments, - /// repairs, and the film stock. A local adjustment is as much an edit as - /// the slider that drives it, so an "original" still wearing its masks - /// would be answering a different question. - /// - /// **Only ever inside a suspension.** This leaves the graph describing a - /// photograph nobody asked for, so every caller restores from a - /// [`EditGraph::state`] taken first. - fn strip_adjustments(&mut self) { - Preset::default().apply(&mut self.graph, Scope::adjustments()); - *self.graph.masks_mut() = dr_pipeline::mask::MaskStack::new(); - *self.graph.spots_mut() = dr_pipeline::SpotSet::new(); - // Through the session rather than the graph: the baked tables live on - // the adjust pass as well, and clearing one without the other is the - // silent disagreement `set_film` exists to prevent. - self.set_film(None); - } - - /// TRACES: FR-DEV-3 | FR-DEV-5 - /// Set the white balance from a point on the photograph. - /// - /// `x` and `y` are fractions of the *visible* image — the coordinates a - /// click on the canvas arrives in — so a photographer inspecting a - /// highlight at 4× samples the pixel they are actually looking at. - /// - /// **Nothing here knows it is white balance.** The colour goes to - /// [`dr_pipeline::neutral`], which finds the operation that asked to be - /// driven by a pixel and inverts its declared response; this side supplies - /// the pixel and the undo step and nothing else. That is FR-DEV-3a's line - /// in its awkward case: a picker genuinely needs to know how far a hundred - /// units of temperature move red against blue, and that number is declared - /// in the node's own file, so the interface must not be the thing that - /// holds a second copy of it. - /// - /// **One step per sample**, and no step at all for a sample that could not - /// be used — a point in the deep shadows has no balance in it to correct. - /// `Edit::Action` never coalesces, so two clicks are two decisions - /// however quickly they follow each other, which is what a photographer - /// trying a wall and then a cloud expects to be able to undo one at a - /// time. - /// - /// Returns whether the photograph moved. - pub fn sample_neutral(&mut self, x: f32, y: f32) -> bool { - let Some(sample) = self.sample_as_shot(x, y) else { - return false; - }; - if !dr_pipeline::neutral::neutralise(&mut self.graph, sample) { - return false; - } - self.history - .record(&self.graph, Edit::Action(labels::step::SAMPLED_NEUTRAL)); - true - } - - /// The colour at a point in the space the white balance gains multiply: - /// camera RGB with the camera's own balance on, linear, nothing else. - /// - /// **Measured where the operation acts, not where the photographer - /// looks.** The white balance node runs first in the chain, on camera - /// RGB, before the body's base curve and its matrix; the canvas shows - /// the pixel after all three. The probe used to be read off a display - /// render with the adjustments stripped, and the solve then treated an - /// sRGB triple as if the gains multiplied it directly. On a JPEG the two - /// spaces coincide, so it worked; on a raw file from any real body the - /// matrix mixes the channels, and a slightly blue wall on a Canon 6D - /// came back tint −77 with the whole frame green. This reads the - /// camera-space tap a merge stitches from — the sensor's numbers after - /// the lens warp — and puts the as-shot balance on itself, which is - /// exactly the value the operation's gains are about to multiply. - /// - /// That also means nothing has to be stripped and restored: the tap - /// runs no operations at all, and the display target is untouched, so - /// a sample that found nothing usable leaves the canvas exactly as it - /// was. - /// - /// The framing is the edit's own, exactly as for [`Self::render_original`] - /// and for the same reason: `x` and `y` are fractions of what is on - /// screen, and a probe rendered without the crop and the zoom would be - /// answering about a different part of the photograph. - /// - /// **A patch, not a point.** The shader fetches the source at one - /// position per output pixel — nearest, or four photosites blended — so - /// a probe of the whole visible region rendered at 192px was not - /// "averaging a neighbourhood into each pixel" as its comment claimed; - /// it was one point sample of a noisy sensor, and two painted-white air - /// conditioners on the same wall answered +37 and −50. Every eyedropper - /// averages for exactly this reason: the photographer is pointing at a - /// grey card, not at a photosite. So the tap is narrowed to the - /// [`PATCH`] of the canvas around the click — a couple of percent of - /// its width, square on screen — and rendered at [`PROBE_PX`] square - /// with interpolation on, which puts a sample on every sensor pixel - /// under the patch at any ordinary zoom. Those are averaged; a sample - /// the tap marked void (outside the frame after the lens correction) or - /// clipped is left out rather than allowed to pull the mean, and if - /// fewer than half the patch survives there was nothing there to - /// balance against. One small dispatch and a 64 KB readback on a click. - fn sample_as_shot(&mut self, x: f32, y: f32) -> Option<[f32; 3]> { - /// Width of the patch as a fraction of what is on the canvas. - const PATCH: f32 = 0.015; - /// Side of the probe render, in pixels. - const PROBE_PX: u32 = 64; - - // Square on screen: the height fraction follows the aspect of the - // visible region, which is the crop's shape times the view's. - let (sw, sh) = self.demosaiced.size(); - let (cw, ch) = self.graph.output_size(sw, sh); - let view = self.graph.framing().view(); - let aspect = (cw as f32 * view.width) / (ch as f32 * view.height).max(f32::EPSILON); - let (pw, ph) = (PATCH, PATCH * aspect); - let patch = dr_pipeline::CropRect { - x: x.clamp(0.0, 1.0) - pw * 0.5, - y: y.clamp(0.0, 1.0) - ph * 0.5, - width: pw, - height: ph, - }; - - let shader = self.graph.compose_camera_probe(patch); - let rendered = self - .adjust - .render_camera_linear(&self.demosaiced, &shader, PROBE_PX, PROBE_PX) - .map(|_| ()); - let (rgba, _, _) = rendered - .and_then(|()| self.adjust.read_camera_linear()) - .inspect_err(|e| log::warn!("could not read a neutral off the frame: {e}")) - .ok()?; - - let mut sum = [0.0f32; 3]; - let mut kept = 0usize; - let mut seen = 0usize; - for pixel in rgba.chunks_exact(4) { - seen += 1; - // The tap marks a pixel the lens correction pulled in from - // outside the frame with alpha 0. There is nothing there to - // balance against. - if pixel[3] < 0.5 { - continue; - } - // Nor in a clipped one. A blown sky reads as sensor white, and - // sensor white with the as-shot balance on is strongly magenta — - // a solve over it drives tint to its stop for a pixel that, on - // the canvas, the shader has already desaturated to neutral. The - // same threshold the shader fades from, so what is refused here - // is what it would have hidden there. - if pixel[..3].iter().any(|c| *c >= dr_pipeline::CLIP_ONSET) { - continue; - } - for (acc, c) in sum.iter_mut().zip(pixel) { - *acc += c; - } - kept += 1; - } - if kept == 0 || kept * 2 < seen { - return None; - } - - // The tap is the sensor's numbers with the profile filled neutral; - // the operation multiplies them *after* the camera's own balance, so - // that goes on here and the solve sees what the gains will see. - let wb = self.demosaiced.as_shot_wb(); - let n = kept as f32; - Some([sum[0] / n * wb[0], sum[1] / n * wb[1], sum[2] / n * wb[2]]) - } - - /// TRACES: FR-PLAT-AND-5 | NFR-RES-1 - /// Give back the GPU memory this session is holding only to be fast. - /// - /// The edit is untouched: the graph and its history are CPU-side by - /// design (ARCH §6.1), so the photograph, the undo stack and the viewport - /// all survive and the next frame simply costs what the first one did. - /// - /// # What is not released, and what it is waiting on - /// - /// The demosaiced source is the largest single allocation a session holds - /// — a 24 MP frame is about 190 MB of `Rgba16Float` — and it is - /// deliberately kept. Dropping it would need the session to be able to - /// rebuild itself from the file, and rebuilding a session from a durable - /// record is FR-PLAT-AND-3, which is not built. Freeing it now would not - /// be an eviction; it would be closing the photograph without telling - /// anyone. Likewise the subject distance fields and the segmentation map: - /// each is guarded by a key recording what it was built from, and freeing - /// one without invalidating its key is the failure `AdjustPass` documents - /// under `colour_key`. - /// - /// So this is the part of the GPU tier that can be given back and asked - /// for again with no other machinery, which is exactly as far as an - /// eviction should go. - pub fn release_gpu_caches(&mut self) { - 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) { - // The lens profile is applied *here* rather than by the caller, and - // that is the point of putting it in this method. This is the one - // place a session is told which file it came from, so it is the one - // place the lookup can be made unforgettable — the same shape - // `FilmRebake` uses to stop a derived thing being quietly skipped. - self.apply_lens_profile(&meta); - self.source_meta = Some(meta); - } - - /// TRACES: FR-DEV-3 - /// Look this shot's lens up and hand the coefficients to the corrections. - /// - /// Called with **every** header, including ones naming no lens: the - /// clearing case matters as much as the setting one, because a session - /// reused for a second photograph would otherwise correct it for the - /// optics of the first. - fn apply_lens_profile(&mut self, meta: &dr_decode::Metadata) { - let found = Self::profile_for(meta); - self.lens_profile_found = found.is_some(); - self.graph.set_lens_profile(found); - } - - /// The profile for one shot, converted into the pipeline's own types. - /// - /// **The conversion lives here because nowhere else can see both sides.** - /// `dr-lens` carries the Lensfun database and `dr-pipeline` carries the - /// maths, and the coefficient structs are deliberately duplicated so that - /// the dependency between them does not exist (ARCH §6.5a). This function - /// is the seam, and it is a `match` on three optionals. - /// - /// Every field is taken independently. The database routinely knows a - /// lens's distortion and not its vignetting, or covers only part of a - /// zoom's range, and a partial profile is worth applying — discarding it - /// because one field is missing would turn a good correction into none. - pub(crate) fn profile_for(meta: &dr_decode::Metadata) -> Option { - // All three are needed to ask the question at all. A lens name alone - // does not identify a correction: distortion is interpolated across a - // zoom's focal range and vignetting depends strongly on aperture — a - // fast prime can be two stops down in the corners wide open and clean - // by f/8 — so a lookup missing either would return a profile measured - // for a shot nobody took. - let (lens, focal, aperture) = (meta.lens.as_deref()?, meta.focal_length?, meta.aperture?); - - let shot = dr_lens::ShotInfo::new(lens, focal, aperture); - let found = dr_lens::lookup(&shot)?; - if found.is_empty() { - return None; - } - - Some(dr_pipeline::LensProfile { - distortion: found - .distortion - .map(|d| dr_pipeline::ops::distortion::PtLens { - a: d.a, - b: d.b, - c: d.c, - }), - tca: found.tca.map(|t| dr_pipeline::Tca { - red_scale: t.red_scale, - blue_scale: t.blue_scale, - }), - vignetting: found.vignetting.map(|v| dr_pipeline::ops::vignetting::Pa { - k1: v.k1, - k2: v.k2, - k3: v.k3, - }), - }) - } - - /// TRACES: FR-DEV-3 - /// What to tell the photographer about the automatic lens correction. - /// - /// `dr-lens` states the rule this exists to satisfy: an automatic - /// correction that silently did nothing is worse than one the user can see - /// is unavailable. Most lenses in most photographs will not be in the - /// database — third-party glass often reports nothing, adapted manual - /// lenses report nothing at all — so "no profile" is the ordinary case and - /// has to read as a fact rather than as a failure. - pub fn lens_summary(&self) -> String { - let Some(meta) = self.source_meta.as_ref() else { - return String::new(); - }; - let Some(lens) = meta - .lens - .as_deref() - .map(str::trim) - .filter(|l| !l.is_empty()) - else { - // Not "no profile found": nothing was looked up, because the file - // does not say what it was taken with. Naming the wrong reason - // would send someone hunting for a profile that was never missing. - return "Lens not recorded".into(); - }; - match (self.lens_profile_found, self.graph.lens_profile_applied()) { - (true, true) => format!("{lens} · corrected"), - // A profile exists and is switched off, which is neither of the - // other two answers: the photographer turned it off, and a line - // reading "no profile" would send them looking for one that is - // sitting right there in the panel with its box unticked. - (true, false) => format!("{lens} · profile off"), - (false, _) => format!("{lens} · no profile"), - } - } - - /// 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 - /// the aspect ratio, and a viewport sized to the sensor would letterbox a - /// cropped image against the wrong shape. - pub fn source_size(&self) -> (u32, u32) { - let (w, h) = self.demosaiced.size(); - self.graph.output_size(w, h) - } - - /// TRACES: FR-EXP-9 - /// Render at full resolution and hand back the pixels, for an export. - /// - /// **Not the frame on screen.** [`Self::render`] deliberately renders at - /// viewport size, which is what keeps a slider inside the frame budget on - /// a 24 MP file (FR-DSP-1) — and what would make an export of it a soft, - /// screen-sized file. This renders the framed output size instead, so the - /// export is the full-quality path FR-EXP-9 requires. - /// - /// This reads pixels back and [`Self::render`] does not, and that is the - /// whole distinction AC-8 draws: a file is made of bytes on the CPU and - /// there is no path to one that avoids the transfer, whereas a frame on - /// screen had no business making the trip. See `AdjustPass::export_pixels` - /// for the longer version. - /// - /// Leaves one of the pass's two targets at full resolution; it is dropped - /// and reallocated on the second display render after this, since the - /// other target still holds a viewport-sized texture and comes up first. - /// Cheaper than keeping a second pass alive for the exports a session - /// rarely performs. - /// - /// `space` is the output colour space the file will claim. It is chosen - /// here rather than at encode time because the conversion happens in the - /// shader, before the clip to 0..1 — by the time pixels reach the encoder - /// they are in exactly one space, and the only honest thing left to do is - /// label them. Asking for the wrong one is a typed error rather than a - /// mislabelled file (FR-EXP-2). - pub fn render_for_export( - &mut self, - space: dr_types::ColourSpace, - ) -> Result { - let (sw, sh) = self.demosaiced.size(); - let (w, h) = self.graph.output_size(sw, sh); - - let (pixels, rw, rh) = self.render_the_file(w, h, space)?; - dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string()) - } - - /// TRACES: FR-EXP-9 | FR-CAT-9 - /// Render the *photograph*, with the viewport suspended, and read it back. - /// - /// **The one thing separating a file from a frame on screen**, and the - /// reason both file-producing paths go through here rather than composing - /// for themselves. [`Framing::view`](../dr_pipeline/framing/struct.Framing.html#method.view) - /// is not an edit — it is kept out of the sidecar, out of `is_active` and - /// out of `output_size` precisely so that zooming cannot change what the - /// file becomes. But it is folded into `visible_rect`, which is the rect - /// the fused shader's prologue samples, so a path that composes the graph - /// and renders it inherits the zoom whether or not it wanted it. Exporting - /// at 4:1 wrote the middle of the frame magnified to fill the file, at the - /// full output size, with the detail kernels scaled four times over — - /// silently, since every dimension the old guard checked still held. - /// - /// Suspended rather than refused: an export is a thing the photographer - /// asks for *while* inspecting a highlight at 4×, and demanding they zoom - /// out first would be answering a question nobody asked. - /// - /// Restored whatever happens, for the reason [`Self::render_uncropped`] - /// restores it: leaving the graph un-zoomed after a failed export would - /// throw away where the photographer was looking. - fn render_the_file( - &mut self, - w: u32, - h: u32, - space: dr_types::ColourSpace, - ) -> Result<(Vec, u32, u32), String> { - let saved_view = self.graph.framing().view(); - self.graph.framing_mut().set_view(CropRect::default()); - - // Composed *inside* the suspension: the view reaches the shader as a - // uniform baked at composition, so composing before this point would - // restore the framing and export the zoom anyway. - let shader = self.graph.compose_for(space); - let rendered = self.render_with_masks(&shader, w, h, space); - - self.graph.framing_mut().set_view(saved_view); - rendered?; - - self.adjust.export_pixels().map_err(|e| e.to_string()) - } - - /// TRACES: FR-CAT-9 - /// Render this edit small, for the grid's thumbnail. - /// - /// **The framed output, not the sensor.** `output_size` is what a crop, a - /// quarter turn, a flip and a straighten all act on, so a thumbnail taken - /// from the raw frame would show the grid a photograph the user no longer - /// has — the right pixels in the wrong shape, still the wrong way up. This - /// is the same path [`Self::render_for_export`] takes, at a size the store - /// wants instead of at full resolution. - /// - /// Always sRGB: this is going into a JPEG in a thumbnail shard that syncs - /// between devices and is drawn as a cell, not a file the user is - /// finishing. The wider spaces exist for export and mean nothing here. - /// - /// Returns width, height and RGBA8. - /// TRACES: FR-DEV-3f - /// The stocks this build can offer, "no film" first. - /// - /// First rather than last so that index zero is the neutral choice: a - /// photograph that has never been put on film selects it without anyone - /// inventing a sentinel, and `reset` means what it means everywhere else. - /// - /// Only what goes in a camera. A print paper is a stock in the database - /// and is chosen *for* a negative rather than instead of one, and so is a - /// cine projection print film — which is coated on film, and is why the - /// filter asks about the stage rather than the support. - pub fn film_choices() -> Vec<(Option<&'static str>, String)> { - let mut out = vec![(None, "None".to_string())]; - out.extend(dr_film::camera_stocks().map(|p| (Some(p.stock.as_str()), p.name.clone()))); - out - } - - /// TRACES: FR-DEV-3f - /// Develop on a named stock, printed or scanned. - /// - /// Baking is milliseconds and happens here rather than being cached, - /// because the tables depend on the exposure parameters as well as the - /// stock: they are what the enlarger was set to, and a cache keyed on the - /// name alone would hand back somebody else's print. - /// - /// A name this build has no profile for clears the film and says so. That - /// is the sync case — a sidecar written on a device with a stock this one - /// lacks — and rendering it as *some other* film would be worse than - /// rendering it plainly. - /// TRACES: FR-DEV-3f | FR-DEV-5 - /// Choose a stock **as the photographer just did**, and record the step. - /// - /// Separate from [`Self::choose_film`] because that call has two very - /// different callers. Picking Portra from the list is an edit and belongs - /// in the history; the same call made while *restoring* an edit — opening - /// a photograph, or stepping to a history row that names a stock — is the - /// second half of putting a state back, and recording it would push a step - /// for the undo the photographer had just asked for. - /// - /// Choosing a stock was not undoable at all before this existed: the pick - /// went straight to `choose_film`, which nothing on the history's path - /// ever sees. - pub fn pick_film(&mut self, stock: Option<&str>, print: bool) { - self.choose_film(stock, print); - self.history - .record(&self.graph, Edit::Action(labels::step::FILM)); - } - - pub fn choose_film(&mut self, stock: Option<&str>, print: bool) { - let Some(stock) = stock else { - self.set_film(None); - return; - }; - let Some(profile) = dr_film::find(stock) else { - log::warn!("no film profile named {stock}; developing without one"); - self.set_film(None); - return; - }; - - // Only a negative has a paper. Asking to print a reversal stock is not - // an error to report, it is a request that has no meaning — so it is - // quietly the same as not asking. - let paper = if print { - dr_film::default_print(profile) - } else { - None - }; - // TRACES: FR-DEV-3f - // Grain, at the scale this photograph is being sampled at. - // - // A digital frame has no film format, so simulating one means choosing - // what it *would have been* — 35 mm, because that is the format every - // published granularity figure and every intuition about how grainy a - // stock looks comes from. The sensor's width in pixels then says how - // much film one pixel covers, and the grain model needs nothing else - // to be correct at any zoom. - // TRACES: FR-DEV-3f - // The frame this is being simulated on, against the pixels it is being - // rendered to: together they are the enlargement, and the enlargement - // is what decides how grainy the result looks. A crystal is a fixed - // size in micrometres — the same emulsion on a sheet averages far more - // of them into each pixel than it does on 35 mm. - let format = dr_film::Format::from_index( - self.graph - .param( - dr_pipeline::ops::film_sim::ID, - dr_pipeline::ops::film_sim::FORMAT, - ) - .unwrap_or(0.0) - .max(0.0) as usize, - ); - let (source_width, _) = self.demosaiced.size(); - let pixel_size_um = format.width_um() / source_width.max(1) as f32; - let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um); - - let baked = dr_film::bake(&dr_film::Recipe { - film: profile, - print: paper, - exposure_ev: self - .graph - .param( - dr_pipeline::ops::film_sim::ID, - dr_pipeline::ops::film_sim::EXPOSURE, - ) - .unwrap_or(0.0), - push_stops: self - .graph - .param( - dr_pipeline::ops::film_sim::ID, - dr_pipeline::ops::film_sim::PUSH, - ) - .unwrap_or(0.0), - print_exposure_ev: self - .graph - .param( - dr_pipeline::ops::film_sim::ID, - dr_pipeline::ops::film_sim::PRINT_EXPOSURE, - ) - .unwrap_or(0.0), - }); - - self.set_film(Some(dr_pipeline::graph::Film { - stock: profile.stock.clone(), - print: paper.map(|p| p.stock.clone()), - tables: dr_pipeline::ops::FilmTables { - exposure_matrix: baked.exposure_matrix, - curves: baked.curves, - curve_log_min: baked.curve_log_min, - curve_log_max: baked.curve_log_max, - lut: baked.lut, - density_max: baked.density_max, - lut_size: baked.lut_size, - grain_particles: grain.particles, - grain_density_max: grain.density_max, - grain_uniformity: grain.uniformity, - }, - })); - } - - /// The stock and paper currently chosen, by id. - pub fn film(&self) -> Option<(&str, bool)> { - self.graph - .film() - .map(|f| (f.stock.as_str(), f.print.is_some())) - } - - /// Re-bake if `op_index` names the film, and do nothing otherwise. - /// - /// `op_index` counts over [`Self::scoped_capabilities`] — the same list - /// [`Self::lookup`] resolves a slider through — so that is the only list to - /// ask. An earlier version also indexed `rows()`, which is one entry per - /// *parameter* and filtered by the active tab: past its end the check - /// short-circuited, the tables were never rebuilt, and the film's own - /// sliders moved nothing at all. - /// - /// The test is here rather than at the call site so the callback in - /// `lib.rs` goes on naming no operation, which is the rule the whole panel - /// is built on (ARCH §4.3a). - pub fn rebake_film_if_affected(&mut self, op_index: i32) { - let is_film = usize::try_from(op_index) - .ok() - .and_then(|i| self.scoped_capabilities().get(i).map(|c| c.id)) - .is_some_and(|id| id == dr_pipeline::ops::film_sim::ID); - if is_film { - self.rebake_film(); - } - } - - /// The stock, the paper, and how far it was developed. - /// - /// Push rides with the other two through every path that re-bakes, because - /// it is the same kind of fact: a decision about the material rather than - /// an adjustment to the picture it produced. - pub fn rebake_film(&mut self) { - if let Some((stock, print)) = self.film().map(|(s, p)| (s.to_string(), p)) { - self.choose_film(Some(&stock), print); - } - } - - /// TRACES: FR-DEV-3f - /// Choose the film stock this session renders through, or clear it. - /// - /// One call, because two places have to agree and they fail *silently* - /// apart. The graph decides whether the generated shader reads the film - /// textures at all; the pass decides what is bound to them. A graph - /// carrying a stock with a pass that is not carrying one samples the 1x1 - /// placeholders, which is a black frame and an error message from nobody. - /// - /// Nothing downstream needs to know the order, so it is fixed here: the - /// pass first, so that the textures are resident before any shader - /// composed from the graph can be dispatched against them. - pub fn set_film(&mut self, film: Option) { - self.adjust.set_film(film.as_ref().map(|f| &f.tables)); - self.graph.set_film(film); - } - - pub fn render_thumbnail(&mut self, edge: u32) -> Result<(u32, u32, Vec), String> { - let (sw, sh) = self.demosaiced.size(); - let (fw, fh) = self.graph.output_size(sw, sh); - let (w, h) = fit(fw, fh, edge.max(1), edge.max(1)); - - let (pixels, rw, rh) = self.render_the_file(w, h, dr_types::ColourSpace::Srgb)?; - Ok((rw, rh, pixels)) - } - - /// The sensor's own dimensions, before framing. - /// - /// What a crop overlay needs: its handles are placed against the full - /// frame, since that is what the user is selecting *from*. - pub fn sensor_size(&self) -> (u32, u32) { - self.demosaiced.size() - } - - /// Whether one source pixel now covers more than one screen pixel. - /// - /// The question the interface asks to decide how the canvas is *filtered*, - /// not how it is rendered. Below 1:1 there are more source pixels than - /// screen pixels and smoothing is what stops the image aliasing; past it - /// there is no more detail to show, and smoothing only invents values - /// between real ones — at which point a photographer inspecting focus or - /// noise wants to see the pixels, not a blur of them. - /// - /// Measured against the visible region rather than the zoom factor alone, - /// because the two differ: a 24 MP file in a 1200px viewport is still - /// showing five sensor pixels per screen pixel at 4×, while a small JPEG is - /// already magnified at 1×. - pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool { - let (sw, sh) = self.demosaiced.size(); - let (fw, fh) = self.graph.output_size(sw, sh); - let (rw, rh) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1)); - - // How many source pixels lie behind the render target: the framed - // image narrowed to the region the view selects. The target keeps its - // size while that region shrinks, which is what raises the ratio. - let view = self.graph.framing().view(); - let behind_w = f64::from(fw) * f64::from(view.width.max(f32::EPSILON)); - let behind_h = f64::from(fh) * f64::from(view.height.max(f32::EPSILON)); - - // Strictly greater, with a margin: at exactly 1:1 either filter gives - // the same answer, and flipping mode on a rounding error would make the - // canvas visibly change character mid-scroll. - f64::from(rw) > behind_w * 1.001 && f64::from(rh) > behind_h * 1.001 - } - - /// Set the crop rectangle, in fractions of the source. - pub fn set_crop(&mut self, rect: CropRect) { - self.graph.set_crop(rect); - // Keyed on the operation, not on a parameter: one drag of one handle - // moves the origin and the extent together. - self.history - .record(&self.graph, Edit::Op(dr_pipeline::framing::ID)); - } - - /// TRACES: FR-DEV-3 - /// Set the crop rectangle, held to `aspect` about `anchor`. - /// - /// The frame size the ratio needs is this session's own, so the caller - /// passes a shape rather than a rectangle and never has to know what a - /// quarter turn did to the frame's dimensions. - /// - /// `anchor` is the point of the rect that must not move, in the rect's own - /// `0..1` coordinates — the corner *opposite* the handle being dragged, so - /// that shaping the rect onto the ratio pushes the held corner and leaves - /// the far one where the user put it. - pub fn set_crop_locked( - &mut self, - rect: CropRect, - aspect: CropAspect, - portrait: bool, - anchor: (f32, f32), - ) { - let frame = self.framed_size(); - let rect = match aspect.ratio(frame, portrait) { - Some(r) => rect.with_aspect(frame.0, frame.1, r, anchor), - None => rect, - }; - self.set_crop(rect); - } - - pub fn crop(&self) -> CropRect { - self.graph.crop() - } - - /// TRACES: FR-DEV-3 - /// The whole frame the crop is measured against, in output pixels. - /// - /// The *framed* size, not the sensor's: quarter turns swap the axes, and a - /// ratio resolved against the sensor would come out on its side the moment - /// a portrait photograph was turned upright. The crop is excluded because - /// this is the shape being selected *from*. - pub fn framed_size(&self) -> (u32, u32) { - let (sw, sh) = self.demosaiced.size(); - self.graph.framing().output_size_uncropped(sw, sh) - } - - /// Rotate by quarter turns, wrapping. The rotate-left/right buttons. - /// - /// The crop travels with the frame rather than staying where it was on - /// screen. A crop is a decision about *this part of the photograph*, and - /// leaving the rect in place while the image turns under it would move the - /// selection onto a different part of the picture — so the rect is turned - /// by the same quarter and the composition survives the rotation. - pub fn rotate_quarters(&mut self, turns: i32) { - let crop = self.graph.crop(); - if !crop.is_full() { - self.graph.set_crop(rotate_crop(crop, turns)); - } - self.graph.rotate_quarters(turns); - self.history - .record(&self.graph, Edit::Action(labels::step::ROTATE)); - } - - /// Straightening, in degrees. Positive turns the image clockwise. - pub fn angle(&self) -> f32 { - self.graph.framing().angle() - } - - /// Quarter turns clockwise, 0..=3 — for the panel's readout. - pub fn quarter_turns(&self) -> u8 { - self.graph.framing().quarter_turns() - } - - pub fn flips(&self) -> (bool, bool) { - self.graph.framing().flips() - } - - /// Mirror horizontally, about the frame's vertical centre line. - pub fn toggle_flip_h(&mut self) { - let (h, _) = self.graph.framing().flips(); - self.graph.set_param( - dr_pipeline::framing::ID, - dr_pipeline::framing::FLIP_H, - f32::from(u8::from(!h)), - ); - self.history - .record(&self.graph, Edit::Action(labels::step::FLIP_H)); - } - - pub fn toggle_flip_v(&mut self) { - let (_, v) = self.graph.framing().flips(); - self.graph.set_param( - dr_pipeline::framing::ID, - dr_pipeline::framing::FLIP_V, - f32::from(u8::from(!v)), - ); - self.history - .record(&self.graph, Edit::Action(labels::step::FLIP_V)); - } - - /// TRACES: FR-DEV-3 - /// The crop the user chose, as distinct from the one currently applied. - /// - /// The remembered intent, but only while it is still credible: if the - /// graph no longer holds what the auto-crop wrote, something else has set - /// the crop since — a handle, a ratio, a sidecar, a paste, an undo — and - /// that new rectangle *is* the intent. See [`Self::auto_crop`]. - fn intended_crop(&self) -> CropRect { - match self.auto_crop { - Some((applied, intended)) if applied == self.graph.crop() => intended, - _ => self.graph.crop(), - } - } - - /// TRACES: FR-DEV-3 - /// Fit the crop to the area the straightening angle leaves defined. - /// - /// Turning a rectangle inside its own bounds exposes its corners: there is - /// no source pixel out there and the shader renders it black. Nothing in - /// the render prevents it — a free angle deliberately does *not* change the - /// output size, so that straightening a horizon leaves the frame where the - /// user put it — which is correct for the drag and leaves black wedges in - /// the corners of the finished photograph. - /// - /// This is the correction, and it runs when the gesture **finishes**. - /// Applied continuously it would fight the drag, shrinking the crop on - /// every frame of the slider. - /// - /// **It grows as well as shrinks.** The crop is recomputed from - /// [`Self::intended_crop`] rather than from itself, so straightening - /// further in takes more away and straightening back out gives it back, - /// stopping at the rectangle the user actually chose. Deriving it from the - /// applied crop instead — the obvious way, and how this first shipped — - /// ratchets: every angle the slider rested at takes its cut and none of - /// them is ever returned, so coming back to zero leaves a crop that - /// nothing on screen explains. - /// - /// The crop keeps its own shape — so a locked ratio survives — and keeps - /// the side of the frame it was on; see [`CropRect::fitted_into`] for why - /// it is not simply replaced by the inscribed rectangle. - pub fn auto_crop_to_angle(&mut self) { - let (sw, sh) = self.demosaiced.size(); - // At zero this is the whole frame, and fitting into it is the identity - // — which is what returns an over-corrected crop to its full size. - // There is deliberately no early exit for the upright case: that exit - // is precisely what would strand the crop small. - let bound = self.graph.framing().max_inscribed_crop(sw, sh); - let intended = self.intended_crop(); - let want = intended.fitted_into(bound); - - if want != self.graph.crop() { - self.graph.set_crop(want); - self.history - .record(&self.graph, Edit::Op(dr_pipeline::framing::ID)); - } - // Recorded even when nothing moved: the pairing is what tells the next - // call that this rectangle is a correction rather than a choice. - self.auto_crop = Some((want, intended)); - } - - /// Set the straightening angle, in degrees. - pub fn set_angle(&mut self, degrees: f32) { - self.graph.set_param( - dr_pipeline::framing::ID, - dr_pipeline::framing::ANGLE, - degrees, - ); - self.history.record( - &self.graph, - Edit::Param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE), - ); - } - - /// Whether the framing currently changes the image — what lights the - /// section's modified dot and enables its reset. - /// - /// Asks whether it *edits*, not whether it is active: a zoomed view makes - /// the framing active without changing the photograph, and a section that - /// claimed an edit because the user scrolled would be lying. - pub fn framing_edits_image(&self) -> bool { - self.graph.framing().edits_image() - } - - /// Return crop, straightening, rotation and flips to neutral, leaving - /// every colour adjustment alone. - /// - /// The zoom is deliberately preserved: it is a viewing state, and resetting - /// the framing is an edit, so throwing away where the user was looking - /// would be an unrelated second effect. - pub fn reset_framing(&mut self) { - let view = self.graph.framing().view(); - self.graph.framing_mut().reset(); - self.graph.framing_mut().set_view(view); - self.history - .record(&self.graph, Edit::Action(labels::step::RESET_FRAMING)); - } - - /// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×. - pub fn zoom(&self) -> f32 { - let v = self.graph.framing().view(); - if v.width <= 0.0 { - 1.0 - } else { - 1.0 / v.width - } - } - - pub fn is_zoomed(&self) -> bool { - self.graph.framing().is_zoomed() - } - - /// Zoom about a point, given in fractions of the *visible* area. - /// - /// Anchoring matters: zooming about the pointer keeps whatever is under - /// it stationary, which is what makes a scroll-wheel zoom feel like it is - /// magnifying the photograph rather than sliding it around. - /// - /// `factor` multiplies the current zoom — above 1 moves in. - pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) { - const MAX_ZOOM: f32 = 16.0; - - let view = self.graph.framing().view(); - let current = if view.width > 0.0 { - 1.0 / view.width - } else { - 1.0 - }; - let target = (current * factor).clamp(1.0, MAX_ZOOM); - // Snapped so scrolling back out reliably reaches "fit" rather than - // stopping a fraction short and leaving the image imperceptibly - // panned. - let target = if (target - 1.0).abs() < 0.01 { - 1.0 - } else { - target - }; - - let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0); - - // The point under the cursor, in framed coordinates, must land back - // under the cursor afterwards. - let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width; - let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height; - - self.set_view_clamped( - anchor_x - at_x.clamp(0.0, 1.0) * extent, - anchor_y - at_y.clamp(0.0, 1.0) * extent, - extent, - ); - } - - /// Pan by a fraction of the *visible* area — what a drag reports. - pub fn pan_by(&mut self, dx: f32, dy: f32) { - let view = self.graph.framing().view(); - self.set_view_clamped( - view.x + dx * view.width, - view.y + dy * view.height, - view.width, - ); - } - - /// Back to fitting the whole frame. - pub fn reset_zoom(&mut self) { - self.graph.framing_mut().set_view(CropRect::default()); - } - - /// TRACES: FR-UI-4 | FR-DSP-1 - /// The zoom that puts one source pixel under one screen pixel. - /// - /// **Derived from the file and the viewport rather than fixed at some - /// multiple**, because 1:1 is not a number: a 60 MP frame in a 1200px - /// viewport needs about 7× before its pixels are its own, and a - /// screen-sized JPEG needs none at all. The same arithmetic - /// [`Self::magnifies_source`] uses to decide how to *filter* the canvas, - /// asked in the other direction — which is what keeps the readout the - /// canvas shows and the zoom this lands on from disagreeing about what - /// 100% means. - /// - /// Never below 1.0: fitting is as far out as the view goes, so a - /// photograph already smaller than the viewport is at 1:1 the moment it - /// is fitted. - pub fn one_to_one_zoom(&self, viewport_w: u32, viewport_h: u32) -> f32 { - let (sw, sh) = self.demosaiced.size(); - let (fw, fh) = self.graph.output_size(sw, sh); - let (rw, _) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1)); - if rw == 0 { - return 1.0; - } - (fw as f32 / rw as f32).max(1.0) - } - - /// TRACES: FR-UI-4 - /// Where the view is centred, in fractions of the framed image. - /// - /// The form the inspection point is *remembered* in, and it has to be - /// this one: the point is carried to the next photograph, and fractions - /// of the frame are the only coordinates two different files share. - pub fn inspection_point(&self) -> (f32, f32) { - let v = self.graph.framing().view(); - (v.x + v.width / 2.0, v.y + v.height / 2.0) - } - - /// TRACES: FR-UI-4 - /// Put the view at 1:1, centred on a point in fractions of the framed - /// image. - /// - /// Separate from [`Self::toggle_inspection`] because the two callers are - /// not the same person: the toggle is a photographer pressing something, - /// and this is the next photograph arriving under the magnifier the last - /// one was left under. - pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) { - let extent = - (1.0 / self.one_to_one_zoom(viewport_w, viewport_h)).clamp(CropRect::MIN_EXTENT, 1.0); - self.set_view_clamped(x - extent / 2.0, y - extent / 2.0, extent); - } - - /// TRACES: FR-UI-4 | FR-DEV-3 - /// Toggle between fitting the frame and inspecting it at 1:1. - /// - /// **Why 1:1 and not "zoom in a bit".** Noise reduction and capture - /// sharpening are judgements about individual pixels, and at a fitted - /// view several source pixels are averaged into each screen pixel — so - /// the frame looks cleaner and softer than it is, and the photographer - /// corrects for a softness the display invented. Over-sharpening is the - /// documented result. There is exactly one magnification at which those - /// two controls are telling the truth, and this is the gesture that - /// reaches it without anyone reading a percentage. - /// - /// `at_x`/`at_y` are fractions of the *visible* area — the same - /// coordinates [`Self::zoom_about`] takes, because they come from the - /// same pointer over the same box. - /// - /// Returns the point now under inspection in fractions of the framed - /// image, or `None` where the view has gone back to fit. That is the - /// answer *after* clamping, so a point near an edge is remembered where - /// the view actually landed rather than where the finger was — otherwise - /// the next photograph would be inspected somewhere the previous one - /// never showed. - /// - /// Leaves the history alone, and must: this changes no pixel of the file. - /// See [`Self::framing_edits_image`] for the same distinction drawn from - /// the other side. - pub fn toggle_inspection( - &mut self, - at_x: f32, - at_y: f32, - viewport_w: u32, - viewport_h: u32, - ) -> Option<(f32, f32)> { - // Out from *any* zoom, not only from 1:1. The gesture means "show me - // the whole photograph again", and a scroll wheel that stopped at - // 173% must not leave the toggle inert. - if self.is_zoomed() { - self.reset_zoom(); - return None; - } - - let view = self.graph.framing().view(); - let x = view.x + at_x.clamp(0.0, 1.0) * view.width; - let y = view.y + at_y.clamp(0.0, 1.0) * view.height; - self.inspect_at(x, y, viewport_w, viewport_h); - Some(self.inspection_point()) - } - - /// Place a square view of `extent`, keeping it inside the frame. - /// - /// Clamped rather than allowed to run off the edge: panning past the - /// boundary would show undefined area beside the photograph, which reads - /// as a rendering fault rather than as the end of the image. - fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) { - let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0); - let max = 1.0 - extent; - self.graph.framing_mut().set_view(CropRect { - x: x.clamp(0.0, max.max(0.0)), - y: y.clamp(0.0, max.max(0.0)), - width: extent, - height: extent, - }); - } - - /// The largest centred crop that, at the current straightening angle, - /// contains no undefined area. What a "straighten and fill" action - /// applies. - pub fn max_inscribed_crop(&self) -> CropRect { - let (w, h) = self.demosaiced.size(); - self.graph.framing().max_inscribed_crop(w, h) - } - - /// How many shader pipelines have been compiled. Surfaced so the status - /// strip can show that slider movement is not recompiling. - pub fn compiled_pipelines(&self) -> usize { - self.adjust.cached_pipelines() - } - - pub fn is_neutral(&self) -> bool { - self.graph.is_neutral() - } - - /// TRACES: FR-DEV-6 - /// Lift this session's edit onto the clipboard. - /// - /// Captured at full scope — framing included — because the decision about - /// what travels is made when the preset is *applied*. Copying, then - /// changing one's mind about the crop, must not mean copying again. - /// TRACES: FR-DEV-3 | FR-CAT-8 - /// The local adjustment stack, for writing this image's edit back. - /// - /// Beside `copy_settings` rather than part of it: that returns a `Preset`, - /// which travels *between* photographs, and a mask must not — it is drawn - /// against one frame and describes nothing on another. The save path takes - /// both; the paste path takes only the preset. - pub fn masks(&self) -> &dr_pipeline::mask::MaskStack { - self.graph.masks() - } - - pub fn copy_settings(&self) -> Preset { - Preset::capture(&self.graph) - } - - /// TRACES: FR-DEV-6 - /// Replace this session's edit within `scope`. - /// - /// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves - /// values the sliders are showing, and nothing here pushes them. - pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) { - preset.apply(&mut self.graph, scope); - // A paste is undoable, and is the action most in need of it: it - // replaces everything in scope at once, so getting it wrong costs more - // than any single control can. - self.history - .record(&self.graph, Edit::Action(labels::step::PASTE)); - } - - /// TRACES: FR-CAT-8 - /// Load a stored edit, as read from this image's sidecar. - /// - /// A replacement rather than an overlay — [`Version::apply`] resets first — - /// so a version that stores nothing opens the photograph at its defaults - /// rather than leaving the previous image's exposure standing. The file's - /// orientation survives it, since that was never an edit. - pub fn apply_version(&mut self, version: &dr_pipeline::Version) { - // TRACES: FR-DEV-3f - // The film, which `apply` cleared and could not restore: a sidecar - // names a stock, and turning a name into tables needs the profile - // database that `dr-pipeline` deliberately does not link. So it is - // re-baked here, after the parameters, because the bake reads the - // film's own exposure sliders and they have just arrived. - let rebake = version.apply(&mut self.graph); - self.pay_film_debt(&rebake); - // The stored edit becomes the floor rather than a step. It is not - // something the user did in this sitting, and an undo that reached - // behind it would discard a previous session's work in one press — - // then persist that on the way out, since saving is automatic. - self.history.reset(&self.graph); - } - - // ---- named snapshots --------------------------------------------------- - - /// TRACES: FR-DEV-5 - /// Hand the session the snapshots its sidecar holds. Called once, on - /// open, beside [`Self::apply_version`]. - pub fn set_snapshots(&mut self, snapshots: Vec) { - self.snapshots = snapshots; - self.removed_snapshots.clear(); - } - - /// The snapshots as they stand, oldest first. - pub fn snapshots(&self) -> &[dr_pipeline::Version] { - &self.snapshots - } - - /// The ids deleted this sitting, for the save. - pub fn removed_snapshots(&self) -> &[String] { - &self.removed_snapshots - } - - /// TRACES: FR-DEV-5 - /// Name the state the photograph is in, and keep it. Returns the id. - /// - /// Not a history step: taking a snapshot changes nothing about the edit, - /// and an undo that removed one would be undoing a decision to remember - /// rather than a change to the photograph. Deleting one is the same. - /// - /// The id is stamped with the second and a per-process random word - /// rather than counted, because two devices can each take a snapshot of - /// the same photograph and both have to survive the merge — which keys - /// on this id, and would fold two `snap-3`s into one. - pub fn take_snapshot(&mut self, name: &str) -> String { - use std::hash::{BuildHasher, Hasher}; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - let salt = std::collections::hash_map::RandomState::new() - .build_hasher() - .finish(); - let id = format!("snap-{now}-{:08x}", salt as u32); - - let name = name.trim(); - let name = if name.is_empty() { - format!("Snapshot {}", self.snapshots.len() + 1) - } else { - name.to_string() - }; - let mut version = dr_pipeline::Version::from_graph(id.clone(), name, &self.graph); - // The stack with the model's coverage folded in, for the reason the - // save uses it: a subject layer stored by identity alone renders as - // nothing until a model is run, and a snapshot restored on the other - // device, or in a batch export, never gets one. - version.masks = self.masks_for_storage(); - version.modified = now; - self.snapshots.push(version); - id - } - - /// TRACES: FR-DEV-5 - /// Put the photograph back the way a snapshot has it. One history step, - /// so it is undoable as a whole, exactly as a paste is. - pub fn restore_snapshot(&mut self, id: &str) -> bool { - let Some(version) = self.snapshots.iter().find(|v| v.uuid == id).cloned() else { - return false; - }; - let rebake = version.apply(&mut self.graph); - self.pay_film_debt(&rebake); - self.history - .record(&self.graph, Edit::Action(labels::step::SNAPSHOT_RESTORED)); - true - } - - /// TRACES: FR-DEV-5 - pub fn rename_snapshot(&mut self, id: &str, name: &str) { - let name = name.trim(); - if name.is_empty() { - return; - } - if let Some(v) = self.snapshots.iter_mut().find(|v| v.uuid == id) { - v.name = name.to_string(); - } - } - - /// TRACES: FR-DEV-5 - /// Forget a snapshot. Remembered as a deletion so the save takes it out - /// of the file rather than merely not putting it back. - pub fn delete_snapshot(&mut self, id: &str) { - let before = self.snapshots.len(); - self.snapshots.retain(|v| v.uuid != id); - if self.snapshots.len() != before { - self.removed_snapshots.push(id.to_string()); - } - if self.compared_snapshot.as_deref() == Some(id) { - self.compared_snapshot = None; - } - } - - /// TRACES: FR-DEV-7 - /// Hold a comparison against a snapshot, or let it go. Returns whether - /// anything changed, so a repeat costs no render. - pub fn compare_snapshot(&mut self, id: Option<&str>) -> bool { - let id = id.filter(|id| self.snapshots.iter().any(|v| v.uuid == *id)); - if self.compared_snapshot.as_deref() == id { - return false; - } - self.compared_snapshot = id.map(str::to_string); - true - } - - /// The snapshot being held against the edit, if one is. - pub fn compared_snapshot(&self) -> Option<&str> { - self.compared_snapshot.as_deref() - } - - /// TRACES: FR-DEV-7 - /// Render the photograph as a snapshot has it, without becoming it. - /// - /// The same suspension [`Self::render_original`] uses — borrow the graph - /// for one render and hand it back — because it is the same question - /// about a different reference point: "the version I liked twenty - /// minutes ago" instead of the file. Nothing is recorded and nothing is - /// marked modified. A held comparison against a snapshot that has since - /// been deleted falls back to the edit itself, which is what is on - /// screen anyway. - pub fn render_compared(&mut self, width: u32, height: u32) -> Result { - let Some(version) = self - .compared_snapshot - .as_deref() - .and_then(|id| self.snapshots.iter().find(|v| v.uuid == id)) - .cloned() - else { - return self.render(width, height); - }; - let saved = self.graph.state(); - let debt = version.apply(&mut self.graph); - self.pay_film_debt(&debt); - let rendered = self.render(width, height); - let debt = self.graph.set_state(&saved); - self.pay_film_debt(&debt); - rendered - } - - /// TRACES: FR-DEV-5 - /// The snapshot list as the panel draws it, oldest first. - pub fn snapshot_rows(&self) -> Vec { - self.snapshots - .iter() - .map(|v| crate::SnapshotRow { - id: v.uuid.as_str().into(), - name: v.name.as_str().into(), - comparing: self.compared_snapshot.as_deref() == Some(v.uuid.as_str()), - }) - .collect() - } - - /// TRACES: FR-DEV-3f | FR-DEV-5 - /// Pay what a restored edit owes the picture. - /// - /// `dr-pipeline` restores a stock's *name* and clears its tables, because - /// baking needs the profile database it does not link (ARCH §6.5a). This - /// side of the seam has it, so this is where the photograph gets its film - /// back. - /// - /// Both outcomes go through [`Self::set_film`], and the empty one is not - /// a no-op: `set_state` cleared the *graph*, and the adjust pass would go - /// on holding textures that nothing will sample. That is the two halves - /// disagreeing, which is the failure `set_film` exists to make - /// impossible — and it is silent in this direction, which is worse. - /// - /// Baked unconditionally rather than only when the stock changed: the - /// tables come from the film node's own exposure sliders as well as from - /// the stock, and restoring an edit replaces those sliders too. A bake is - /// milliseconds and this happens on a keypress, so the cheap correct rule - /// beats the clever one. - fn pay_film_debt(&mut self, rebake: &dr_pipeline::FilmRebake) { - match rebake.wanted() { - Some(film) => { - let stock = film.stock.clone(); - let print = film.print.is_some(); - self.choose_film(Some(&stock), print); - } - None => self.set_film(None), - } - } - - /// TRACES: FR-DEV-5 - /// [`Self::pay_film_debt`] for a history step, when the step went - /// anywhere. - /// - /// The guard is the whole difference between the two: a step that found - /// nowhere to go left the graph alone, and clearing the film because - /// undo hit the floor would take the picture's stock off it. - fn settle(&mut self, step: &dr_pipeline::Step) { - if let dr_pipeline::Step::Took(rebake) = step { - self.pay_film_debt(rebake); - } - } - - /// TRACES: FR-DEV-5 - /// Step the edit back one, returning whether anything moved. - /// - /// The panel must be rebuilt from [`Self::rows`] afterwards, for the same - /// reason a paste must: this moves values the controls are showing and - /// nothing here pushes them. - pub fn undo(&mut self) -> bool { - let step = self.history.undo(&mut self.graph); - self.settle(&step); - step.moved() - } - - /// TRACES: FR-DEV-5 - /// Step the edit forward one, returning whether anything moved. - pub fn redo(&mut self) -> bool { - let step = self.history.redo(&mut self.graph); - self.settle(&step); - step.moved() - } - - pub fn can_undo(&self) -> bool { - self.history.can_undo() - } - - pub fn can_redo(&self) -> bool { - self.history.can_redo() - } - - /// TRACES: FR-DEV-5 | FR-DEV-7 - /// Every step this photograph has been through, newest first. - /// - /// Newest first because the list is consulted to take back something just - /// done, not browsed chronologically — the order `dr_catalog::trash` - /// settled on for the same question. It also keeps the interesting end - /// against the heading, so a stack sixty-four deep does not put the step - /// the photographer is looking for at the bottom of a long scroll. - /// - /// The reversal happens here rather than in the core, which returns the - /// stack in stack order and stamps each row with its own index — so - /// nothing on this side does arithmetic to turn a row back into a step. - pub fn history_rows(&self) -> Vec { - let mut rows: Vec<_> = self - .history - .entries(&self.graph) - .into_iter() - .map(|entry| crate::HistoryRow { - index: entry.index as i32, - label: labels::resolve(entry.label.0).into(), - current: entry.current, - // Everything past the mark is a future the photographer - // stepped out of. Still listed, because it is still reachable - // by redo and hiding it would make redo arrive somewhere the - // panel never mentioned — but drawn as the branch it is. - undone: entry.index > self.history.cursor(), - }) - .collect(); - rows.reverse(); - rows - } - - /// TRACES: FR-DEV-5 | FR-DEV-7 - /// Step straight to one row of [`Self::history_rows`]. - /// - /// Takes the row's own `index`, not its position in that list. - /// TRACES: FR-DEV-5 - /// A number that changes exactly when [`Self::history_rows`] would. - /// - /// The panel is rebuilt off this rather than every redraw: a drag ends in - /// a redraw per frame and changes no row, and pushing a fresh model makes - /// the toolkit tear down and recreate every one of them. - pub fn history_revision(&self) -> u64 { - self.history.revision() - } - - pub fn go_to_history(&mut self, index: i32) -> bool { - let Ok(index) = usize::try_from(index) else { - return false; - }; - let step = self.history.go_to(&mut self.graph, index); - self.settle(&step); - step.moved() - } - - /// TRACES: FR-DEV-5 - /// What undo would take back, and what redo would put back. - /// - /// Named on the buttons rather than left to the bare verb. "Undo" asks the - /// photographer to remember what they last did, which after a run of small - /// adjustments is exactly what they have stopped tracking — and it is the - /// moment they are least willing to press a button and find out. - /// - /// Empty when there is nowhere to go, so the caller falls back to the verb - /// alone rather than printing a label for a disabled control. - pub fn undo_label(&self) -> String { - // Undo takes back the step the graph is *standing on*, so the row to - // name is the current one — not the one it will land on. - self.step_name(self.history.cursor(), self.history.can_undo()) - } - - /// TRACES: FR-DEV-5 - pub fn redo_label(&self) -> String { - self.step_name(self.history.cursor() + 1, self.history.can_redo()) - } - - fn step_name(&self, index: usize, offered: bool) -> String { - if !offered { - return String::new(); - } - self.history - .entries(&self.graph) - .into_iter() - .find(|e| e.index == index) - .map(|e| labels::resolve(e.label.0)) - .unwrap_or_default() - } -} - -/// Re-express a crop rect after the frame it is measured against turns. -/// -/// The crop lives in fractions of the *framed* image — the one the quarter -/// turns have already produced — so turning the frame another quarter leaves -/// the rect describing the wrong region unless it turns with it. Without this, -/// rotating a portrait crop on a landscape photograph slides the selection -/// onto a different part of the picture, which reads as the rotation having -/// moved the image rather than the frame. -/// -/// One clockwise quarter takes `(x, y)` to `(1 - y - h, x)` and exchanges the -/// extents; anticlockwise is the same map run the other way. Applied -/// `turns.rem_euclid(4)` times so the caller's wrapping and this agree. -fn rotate_crop(rect: CropRect, turns: i32) -> CropRect { - let mut r = rect; - for _ in 0..turns.rem_euclid(4) { - r = CropRect { - x: 1.0 - r.y - r.height, - y: r.x, - width: r.height, - height: r.width, - }; - } - r.normalised() -} - -/// Sort ascending and force a minimum separation. -/// -/// Mirrors what the curve operation does before handing points to the -/// shader. Duplicated rather than shared because the operation keeps it -/// private, and the consequence of drift is only a drawn line that lags the -/// rendered one by a pixel — not a wrong image. -fn sort_with_gap(xs: &mut [f32]) { - const MIN_GAP: f32 = 0.001; - for i in 1..xs.len() { - let mut j = i; - while j > 0 && xs[j - 1] > xs[j] { - xs.swap(j - 1, j); - j -= 1; - } - } - for i in 1..xs.len() { - if xs[i] - xs[i - 1] < MIN_GAP { - xs[i] = xs[i - 1] + MIN_GAP; - } - } -} - -/// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect. -/// -/// Rendering to the letterboxed size rather than the full viewport avoids -/// shading pixels the view will not show, which at a 3:2 image in a 16:9 -/// window is a fifth of them. -fn fit(sw: u32, sh: u32, max_w: u32, max_h: u32) -> (u32, u32) { - if sw == 0 || sh == 0 { - return (max_w, max_h); - } - let scale = (max_w as f32 / sw as f32).min(max_h as f32 / sh as f32); - // Never upscale past the source: there is no detail to recover, and a - // 1:1 render is cheaper. - let scale = scale.min(1.0); - ( - ((sw as f32 * scale).round() as u32).max(1), - ((sh as f32 * scale).round() as u32).max(1), - ) -} - -/// The order an operation's parameters are shown in. -/// -/// Declaration order, unless the operation facets them — in which case -/// parameters sharing an aspect are brought together, so the panel names -/// each run once instead of repeating "Hue / Saturation / Luminance" -/// twelve times over. The colour mixer declares band by band, which is the -/// order the shader wants; a photographer works channel by channel. -/// -/// **This is presentation, and so it lives here** (ARCH §4.3a). The core -/// says which aspect a parameter belongs to; deciding that an aspect is -/// worth stacking rows by is the panel's composition to make, exactly as -/// grouping by operation is. Routing is unaffected — `param_index` stays -/// the position in the capability list however the rows are stacked. -/// -/// A stable sort by the aspect's first appearance, so an operation with no -/// facets comes back untouched, and one that mixes plain parameters with -/// faceted ones keeps the plain ones first and in order. -fn presentation_order(params: &[dr_pipeline::ParamCapability]) -> Vec { - let mut aspects: Vec<&str> = Vec::new(); - let rank: Vec = params - .iter() - .map(|p| match &p.facet { - None => 0, - Some(f) => { - let at = aspects.iter().position(|a| *a == f.aspect.0); - // First appearance defines the run's place, so the panel's - // sections come out in the order the operation introduced - // them rather than alphabetically. - 1 + at.unwrap_or_else(|| { - aspects.push(f.aspect.0); - aspects.len() - 1 - }) - } - }) - .collect(); - - let mut order: Vec = (0..params.len()).collect(); - order.sort_by_key(|i| rank[*i]); - order -} - -/// Suffix shown after a value. Comes from the descriptor's declared unit, so -/// this function needs no knowledge of which parameter it is formatting. -fn unit_suffix(unit: Unit) -> &'static str { - match unit { - Unit::None => "", - Unit::Stops => " EV", - Unit::Kelvin => " K", - Unit::Percent => "%", - } -} - -/// `dr_pipeline`'s morphology, as `dr_segment` names it. -/// -/// Two enums for one idea, and deliberately: `dr-pipeline` describes the -/// *edit* and `dr-segment` implements the *transform*, and neither depends on -/// the other. The crossing is this function, which the compiler makes -/// exhaustive on both sides. -fn morphology_for(m: dr_pipeline::mask::Morphology) -> dr_segment::Morphology { - use dr_pipeline::mask::Morphology as Edit; - use dr_segment::Morphology as Transform; - match m { - Edit::None => Transform::None, - Edit::Dilate => Transform::Dilate, - Edit::Erode => Transform::Erode, - Edit::Close => Transform::Close, - Edit::Open => Transform::Open, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use dr_pipeline::EditGraph; - - // --- the crop ratio lock --------------------------------------------- - - #[test] - fn a_locked_ratio_is_resolved_in_output_pixels() { - // 3:2 means three pixels across to two down, whatever shape the frame - // it is being cut out of happens to be. - let landscape = CropAspect::Fixed(3, 2); - assert_eq!(landscape.ratio((6000, 4000), false), Some(1.5)); - assert_eq!(landscape.ratio((4000, 6000), false), Some(1.5)); - // Stood on its short edge. - assert_eq!(landscape.ratio((6000, 4000), true), Some(2.0 / 3.0)); - } - - #[test] - fn the_frames_own_ratio_follows_the_frame() { - // What separates `Original` from naming the same numbers: it is right - // on the next photograph from another body, and after a quarter turn. - let a = CropAspect::Original; - assert_eq!(a.ratio((6000, 4000), false), Some(1.5)); - assert_eq!(a.ratio((4000, 6000), false), Some(2.0 / 3.0)); - assert_eq!(a.ratio((5000, 5000), false), Some(1.0)); - } - - #[test] - fn free_locks_nothing() { - assert_eq!(CropAspect::Free.ratio((6000, 4000), false), None); - assert_eq!(CropAspect::Free.ratio((6000, 4000), true), None); - assert!(!CropAspect::Free.has_orientation()); - } - - #[test] - fn a_square_has_no_second_orientation() { - // Turning it would be a control that visibly does nothing, so the - // switch is disabled and the flag is ignored either way. - let square = CropAspect::Fixed(1, 1); - assert!(!square.has_orientation()); - assert_eq!(square.ratio((6000, 4000), true), Some(1.0)); - assert_eq!(square.ratio((6000, 4000), false), Some(1.0)); - } - - #[test] - fn only_a_named_ratio_has_to_be_turned_with_the_frame() { - // The distinction that stops `Original` being flipped twice: a quarter - // turn swaps the frame's axes, so a ratio resolved *against* the frame - // has already turned by the time anything asks it. - assert!(CropAspect::Fixed(16, 9).turns_with_the_frame()); - assert!(CropAspect::Fixed(3, 2).turns_with_the_frame()); - assert!(!CropAspect::Original.turns_with_the_frame()); - assert!(!CropAspect::Free.turns_with_the_frame()); - assert!(!CropAspect::Fixed(1, 1).turns_with_the_frame()); - } - - #[test] - fn a_quarter_turn_leaves_a_locked_crop_the_shape_it_already_was() { - // The whole reason the switch is flipped on a quarter turn. A crop - // locked to 16:9 is carried through the turn by `rotate_crop`, coming - // out at 9:16 of a frame whose axes have also swapped — so the lock - // must now read as portrait, or the next drag would snap the crop back - // upright and undo what the turn did to the composition. - let (fw, fh) = (6000u32, 4000u32); - let aspect = CropAspect::Fixed(16, 9); - let before = CropRect::default().with_aspect( - fw, - fh, - aspect.ratio((fw, fh), false).unwrap(), - (0.5, 0.5), - ); - - let after = rotate_crop(before, 1); - let (tw, th) = (fh, fw); - let got = (after.width * tw as f32) / (after.height * th as f32); - let want = aspect.ratio((tw, th), true).unwrap(); - assert!( - (got / want - 1.0).abs() < 1e-3, - "turned crop is {got}, the flipped lock says {want}" - ); - } - - #[test] - fn every_offered_ratio_has_a_name_and_a_place() { - // The chips are drawn from this list, so a duplicate would light two - // at once and an empty label would draw a blank button. - let mut seen = Vec::new(); - for a in CropAspect::CHOICES { - assert!(!a.label().is_empty(), "{a:?} has no label"); - assert!(!seen.contains(&a), "{a:?} is offered twice"); - seen.push(a); - } - assert_eq!( - CropAspect::CHOICES[0], - CropAspect::Free, - "free is the default" - ); - assert_eq!(CropAspect::default(), CropAspect::Free); - } - - /// TRACES: FR-DSP-1 | AC-8 - /// Copy a displayed frame back to the CPU, for assertions and nothing else. - /// - /// The library has no such function on purpose: S1 removed the display - /// readback, and AC-8 is the assertion that it stayed removed. A test that - /// wants to look at the pixels therefore has to do the copy itself, which - /// is exactly the right shape — the round-trip lives in the test binary - /// and cannot be reached from a shipping one. - /// - /// Doubles as the proof: this only compiles because the image *is* a wgpu - /// texture. Hand it a `SharedPixelBuffer`-backed image and it panics. - fn read_back(ctx: &GpuContext, image: &slint::Image) -> Vec { - let texture = image - .to_wgpu_29_texture() - .expect("the develop canvas must be a GPU texture, not a pixel buffer"); - let (w, h) = (texture.width(), texture.height()); - - // Buffer rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT. - let unpadded = w * 4; - let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; - let padded = unpadded.div_ceil(align) * align; - - let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("test-readback"), - size: u64::from(padded * h), - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - - let mut enc = ctx.device.create_command_encoder(&Default::default()); - enc.copy_texture_to_buffer( - wgpu::TexelCopyTextureInfo { - texture: &texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyBufferInfo { - buffer: &buf, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(padded), - rows_per_image: Some(h), - }, - }, - wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: 1, - }, - ); - ctx.queue.submit(Some(enc.finish())); - - let slice = buf.slice(..); - let (tx, rx) = std::sync::mpsc::channel(); - slice.map_async(wgpu::MapMode::Read, move |r| { - let _ = tx.send(r); - }); - ctx.device - .poll(wgpu::PollType::wait_indefinitely()) - .expect("poll"); - rx.recv().expect("map").expect("map"); - - let data = slice.get_mapped_range(); - let mut out = Vec::with_capacity((unpadded * h) as usize); - for row in 0..h { - let start = (row * padded) as usize; - out.extend_from_slice(&data[start..start + unpadded as usize]); - } - drop(data); - buf.unmap(); - out - } - - // ---------------------------------------------------------------------- - // Segmentation off the UI thread - // ---------------------------------------------------------------------- - - /// The property the whole arrangement rests on. - /// - /// If someone puts an `Rc`, a `Cell` or a raw pipeline handle into - /// `SegmentationJob`, this stops compiling — which is the only warning - /// there would be, since the call site in `masks_ui` would then fail with - /// a lifetime error a long way from the cause. - #[test] - fn a_job_and_its_answer_can_cross_a_thread() { - fn is_send() {} - is_send::(); - is_send::(); - is_send::(); - } - - /// Two sessions over the same file are still two photographs as far as a - /// late result is concerned, because opening one twice is opening it - /// twice. - #[test] - fn every_session_has_its_own_identity() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); - let open = || { - DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) - .expect("session") - }; - let (a, b) = (open(), open()); - assert_ne!(a.id(), b.id()); - assert_eq!(a.id(), a.id(), "and stable within one session"); - } - - #[test] - fn a_job_carries_the_session_it_was_taken_from() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); - let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) - .expect("session"); - assert_eq!(session.segmentation_job().session(), session.id()); - } - - /// Abandoning before the run reaches the proxy must cost nothing at all — - /// this is the case that fires when the user pages on while a job is still - /// waiting for a thread. - #[test] - fn an_abandoned_job_does_no_work() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); - let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) - .expect("session"); - - let job = session.segmentation_job(); - job.abandon().now(); - - let started = std::time::Instant::now(); - let out = job.run(&crate::segmentation::Options::default()); - assert!( - matches!(out, Ok(None)), - "abandoned is not an error and not an empty answer: {:?}", - out.map(|o| o.is_some()) - ); - assert!( - started.elapsed() < std::time::Duration::from_millis(50), - "it returned without loading the model" - ); - } - - /// A finished segmentation is adopted whole, and the session says so. - #[test] - fn adopting_a_result_gives_the_session_its_subjects() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect(); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL) - .expect("session"); - assert!(!session.has_segmentation()); - - let job = session.segmentation_job(); - let Ok(Some(found)) = job.run(&crate::segmentation::Options::default()) else { - eprintln!("no model; skipping"); - return; - }; - session.adopt_segmentation(found); - assert!(session.has_segmentation()); - } - - // ---------------------------------------------------------------------- - // The overlay's clip rectangle - // ---------------------------------------------------------------------- - // - // The overlay is a source-space picture and the canvas shows whatever the - // crop, the zoom and the pan selected out of that space. Drawn whole it - // stays frame-sized while the photograph moves underneath, which is what - // these pin down. - - /// A session with a segmentation, so the clip has a proxy to measure - /// against. - /// - /// The model finds nothing in flat grey, and that is fine: the clip is - /// computed from the framing and the proxy size, neither of which depends - /// on what was detected. - fn segmented_session(ctx: &GpuContext) -> Option { - let rgba: Vec = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect(); - let mut session = - DevelopSession::open_rgb(ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL) - .expect("session"); - session - .segment(&crate::segmentation::Options::default()) - .ok()?; - Some(session) - } - - /// One device for the whole test binary. - /// - /// This opened a *new* `GpuContext` per test, and `cargo test` runs tests - /// on as many threads as there are cores — so a full run asked the driver - /// to bring up a dozen Vulkan devices at once and the binary died with - /// SIGSEGV. Serially it passed, which is what made it look like flakiness - /// rather than a bug in the harness. - /// - /// A `GpuContext` is an `Arc` and an `Arc`, so sharing one - /// is a refcount rather than a copy, and wgpu is explicit that both are - /// safe to use from several threads. Nothing here mutates the context; the - /// per-test state is in the passes and the sessions built on top of it. - /// - /// `OnceLock` rather than `lazy_static`: the initialiser runs once however - /// many threads arrive together, and the losers block until it is done — - /// which is precisely the property that was missing. - fn headless() -> Option { - static SHARED: std::sync::OnceLock> = std::sync::OnceLock::new(); - SHARED - .get_or_init(|| pollster::block_on(dr_gpu::GpuContext::new_headless()).ok()) - .clone() - } - - /// Every attribute the chain actually carries must reach the tab strip. - /// - /// The strip is generated, so an attribute with operations behind it and - /// no tab in front of it is unreachable — the controls exist, are in the - /// shader, and cannot be filtered to. That is exactly how `Optics` sat - /// invisible for as long as it had no operations, and the failure looks - /// identical from the outside whether the cause is an empty category or a - /// broken filter. - #[test] - fn every_attribute_with_operations_gets_a_tab() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); - let session = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) - .expect("session"); - - let tabs: Vec = - session.tabs().into_iter().map(|(a, _)| a).collect(); - - for attribute in dr_pipeline::Attribute::ALL { - // Compose is deliberately absent: its one stage prefers an - // on-canvas widget, so a Compose tab would open onto nothing while - // `ComposePanel` holds the real controls. - if attribute == dr_pipeline::Attribute::Compose { - continue; - } - let carried = dr_pipeline::EditGraph::default_chain() - .capabilities() - .iter() - .any(|c| c.attributes.contains(&attribute) && !c.params.is_empty()); - assert_eq!( - tabs.contains(&attribute), - carried, - "{attribute:?}: carried by the chain = {carried}, has a tab = {}", - tabs.contains(&attribute) - ); - } - } - - /// A lookup needs all three of lens, focal length and aperture. - /// - /// Not pedantry about missing fields: distortion is interpolated across a - /// zoom's focal range and vignetting depends strongly on aperture, so a - /// lookup done without them would return coefficients measured for a shot - /// nobody took and apply them with full confidence. Refusing is the honest - /// answer, and the panel says so. - #[test] - fn a_lookup_needs_the_whole_shot_and_not_just_the_lens() { - let complete = dr_decode::Metadata { - lens: Some("Nikon AF-S 50mm f/1.8G".into()), - focal_length: Some(50.0), - aperture: Some(1.8), - ..Default::default() - }; - - for (name, meta) in [ - ( - "no lens", - dr_decode::Metadata { - lens: None, - ..complete.clone() - }, - ), - ( - "no focal length", - dr_decode::Metadata { - focal_length: None, - ..complete.clone() - }, - ), - ( - "no aperture", - dr_decode::Metadata { - aperture: None, - ..complete.clone() - }, - ), - ] { - assert!( - DevelopSession::profile_for(&meta).is_none(), - "{name}: a partial header must not produce a confident profile" - ); - } - } - - /// "Not recorded" and "no profile" are different facts. - /// - /// Collapsing them would send someone hunting for a missing profile when - /// the file simply never said what took the photograph — and `dr-lens`'s - /// own rule is that the interface must be plain about which it is, because - /// a correction that silently did nothing is worse than one visibly - /// unavailable. - #[test] - fn the_lens_line_says_which_kind_of_nothing_it_found() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); - - let session = |meta: dr_decode::Metadata| { - let mut s = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) - .expect("session"); - s.set_source_metadata(meta); - s - }; - - // A session that was never given a header at all. - let bare = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) - .expect("session"); - assert_eq!(bare.lens_summary(), ""); - - let unrecorded = session(dr_decode::Metadata::default()); - assert_eq!(unrecorded.lens_summary(), "Lens not recorded"); - - // A name no database will match. Deliberately absurd rather than a real - // obscure lens, so the test cannot start passing for the wrong reason - // if the bundled database grows. - let unmatched = session(dr_decode::Metadata { - lens: Some("Nonexistent 999mm f/0.5".into()), - focal_length: Some(999.0), - aperture: Some(0.5), - ..Default::default() - }); - assert_eq!( - unmatched.lens_summary(), - "Nonexistent 999mm f/0.5 · no profile" - ); - assert!( - unmatched.graph.lens_profile().is_none(), - "an unmatched lens must leave the corrections alone" - ); - } - - /// TRACES: FR-DEV-3 - /// A profile switched off is a third answer, and has to read as one. - /// - /// "No profile" sends a photographer looking for a lens the database does - /// not have. If the profile is sitting in the panel with its box unticked, - /// that is a different sentence, and the line has to say which. - /// - /// Depends on the bundled database holding a common lens, exactly as - /// `dr_lens`'s own tests do — this is the only path that sets - /// `lens_profile_found`, and standing a profile up by hand would test the - /// formatting while skipping the lookup it is reporting on. - #[test] - fn the_lens_line_separates_a_declined_profile_from_a_missing_one() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) - .expect("session"); - - const LENS: &str = "Canon EF 16-35mm f/2.8L USM"; - session.set_source_metadata(dr_decode::Metadata { - lens: Some(LENS.into()), - focal_length: Some(20.0), - aperture: Some(2.8), - ..Default::default() - }); - assert_eq!(session.lens_summary(), format!("{LENS} · corrected")); - - session.graph.set_lens_profile_applied(false); - assert_eq!(session.lens_summary(), format!("{LENS} · profile off")); - - session.graph.set_lens_profile_applied(true); - assert_eq!(session.lens_summary(), format!("{LENS} · corrected")); - } - - /// Opening a second photograph must not correct it for the first one's lens. - /// - /// The clearing case, and the reason `apply_lens_profile` runs on every - /// header rather than only on the ones that match something. A stale - /// profile is invisible: the picture is simply wrong in a way that looks - /// like the lens. - #[test] - fn a_second_photograph_does_not_inherit_the_first_lens_profile() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) - .expect("session"); - - // Stand in for a matched lens by applying a profile directly, so the - // test does not depend on what the bundled database happens to hold. - session - .graph - .set_lens_profile(Some(dr_pipeline::LensProfile { - distortion: Some(dr_pipeline::ops::distortion::PtLens { - a: 0.0, - b: -0.02, - c: 0.0, - }), - tca: None, - vignetting: None, - })); - assert!(session.graph.lens_profile().is_some()); - - session.set_source_metadata(dr_decode::Metadata::default()); - - assert!( - session.graph.lens_profile().is_none(), - "a header naming no lens must clear the previous photograph's \ - correction, not leave it standing" - ); - } - - /// TRACES: FR-DEV-3 - /// A gradient needs no segmentation, and until now it silently got no mask. - /// - /// The rasteriser was built on the way out of `segment`, so a gradient - /// added to a photograph nobody had segmented had nothing to draw it — and - /// the failure was invisible from every side. The generated shader still - /// emits the layer's block, the empty placeholder multiplies it by zero, - /// and the result is a well-formed frame with the local adjustment simply - /// absent. No error, no warning, and nothing on screen to tell it apart - /// from a mask the user had placed badly. - /// - /// A graduated filter over a sky never had to know what a sky is, so the - /// dependency was wrong as well as silent. - #[test] - fn a_gradient_renders_on_a_photograph_nobody_has_segmented() { - let Some(ctx) = headless() else { return }; - - // Mid grey, so a brightening layer is unambiguous either way. - let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) - .expect("session"); - assert!( - !session.has_segmentation(), - "the point of the test is that there is none" - ); - - let before = read_back(&ctx, &session.render(64, 64).expect("render")); - - // A radial over the middle, brightened hard. Addressed by index, so - // this names no operation (FR-DEV-3a). - session.add_gradient_mask(true).expect("a radial"); - let row = session.rows()[0].clone(); - session.set_param(row.op_index, row.param_index, row.maximum); - - let after = read_back(&ctx, &session.render(64, 64).expect("render")); - - let centre = |px: &[u8]| px[((32 * 64 + 32) * 4) as usize]; - assert!( - centre(&after) > centre(&before) + 20, - "the middle of the frame must brighten: {} against {}", - centre(&after), - centre(&before) - ); - // And only the middle: a mask that failed to rasterise the other way — - // covering everything — would pass the assertion above. - let corner = |px: &[u8]| px[0]; - assert_eq!( - corner(&after), - corner(&before), - "the corner is outside the radial and must not move" - ); - } - - // ---------------------------------------------------------------------- - // Stored coverage (dr_pipeline::coverage) - // ---------------------------------------------------------------------- - // - // A subject or category layer is stored in the sidecar as identity — which - // run, which instance, which category — and identity resolves to pixels - // only while that run is in memory. So reopening an edited photograph - // dropped every model-backed local adjustment, and a batch export, which - // never runs a model at all, could not have them at any point. Both - // failures were silent: the shader still emitted the layer's block, the - // placeholder multiplied it by zero, and the result was a well-formed - // frame with the adjustment simply absent. - - /// A stored edit holding one subject layer over the left half of the - /// frame, as a session that *had* run the model would have written it. - /// - /// `stored` is the whole variable: with the raster, this is a sidecar - /// written by a build that persists coverage; without it, one written - /// before that existed. Everything else about the two is identical, which - /// is what makes the pair of tests below a measurement rather than an - /// assertion about two different edits. - fn version_with_a_subject(proxy: (u32, u32), stored: bool) -> dr_pipeline::Version { - use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS}; - - let (pw, ph) = (proxy.0 as usize, proxy.1 as usize); - let mut values = vec![0u8; pw * ph]; - for y in 0..ph { - for x in 0..pw / 2 { - values[y * pw + x] = 255; - } - } - - let mut layer = MaskLayer::new( - "m1", - MaskSource::Subject { - signature: 0xfeed, - index: 0, - class: "dog".into(), - score: 0.9, - }, - ); - layer.set_param("exposure", ParamId("exposure"), 2.0); - if stored { - layer.base_mut().coverage = Some(Arc::new( - Coverage::encode(&values, pw, ph, RENDERED_LEVELS).expect("a half frame encodes"), - )); - } - - let mut graph = EditGraph::default_chain(); - graph.masks_mut().push(layer); - dr_pipeline::Version::from_graph("default", "Default", &graph) - } - - /// A flat grey session with nothing segmented, and its render. - fn grey_session(ctx: &GpuContext) -> (DevelopSession, Vec) { - let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); - let mut session = - DevelopSession::open_rgb(ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) - .expect("session"); - assert!( - !session.has_segmentation(), - "the premise: no model has been run" - ); - let before = read_back(ctx, &session.render(64, 64).expect("render")); - (session, before) - } - - /// TRACES: FR-UI-4 - /// The inspection zoom is 1:1 for *this* file in *this* viewport. - /// - /// The number is the whole point. A magnifier that lands on some fixed - /// multiple tells the photographer nothing about whether they are looking - /// at the file's own pixels, and that is the only question noise reduction - /// and capture sharpening can honestly be judged by. - #[test] - fn inspecting_lands_on_one_source_pixel_per_screen_pixel() { - let Some(ctx) = headless() else { return }; - let (mut session, _) = grey_session(&ctx); - - // Sixty-four source pixels fitted into thirty-two is one screen pixel - // per two of the file's, so 1:1 is 2×. - let one_to_one = session.one_to_one_zoom(32, 32); - assert!( - (one_to_one - 2.0).abs() < 1e-3, - "a 64px frame in a 32px viewport is 2× at 1:1, not {one_to_one}" - ); - - assert!( - session.toggle_inspection(0.5, 0.5, 32, 32).is_some(), - "the first toggle goes in" - ); - assert!( - (session.zoom() - one_to_one).abs() < 1e-3, - "the view should have landed on 1:1, not {}", - session.zoom() - ); - } - - /// TRACES: FR-UI-4 - /// The second press goes back to fit — from any zoom, not only from 1:1. - /// - /// A scroll wheel that stopped at 173% must not leave the toggle inert: - /// the gesture means "show me the whole photograph again". - #[test] - fn the_inspection_toggle_returns_to_fit_from_any_zoom() { - let Some(ctx) = headless() else { return }; - let (mut session, _) = grey_session(&ctx); - - session.zoom_about(3.0, 0.5, 0.5); - assert!(session.is_zoomed(), "the premise"); - - assert_eq!( - session.toggle_inspection(0.5, 0.5, 32, 32), - None, - "toggling out reports no inspection point" - ); - assert!(!session.is_zoomed()); - } - - /// TRACES: FR-UI-4 | FR-DEV-5 - /// Inspecting is a way of looking, and leaves no trace on the photograph. - /// - /// The failure this guards is quiet and expensive: a zoom that recorded a - /// step would put a viewport rectangle on the undo stack and into the - /// sidecar, and the photograph would then open on another device cropped - /// to wherever somebody once looked. - #[test] - fn inspecting_writes_nothing_the_file_would_remember() { - let Some(ctx) = headless() else { return }; - let (mut session, _) = grey_session(&ctx); - assert!(!session.can_undo(), "the premise: nothing has been done"); - - session.toggle_inspection(0.25, 0.75, 32, 32); - - assert!(!session.can_undo(), "a zoom is not a step to take back"); - assert!(session.is_neutral(), "and it is not an edit either"); - assert!(!session.framing_edits_image()); - } - - /// TRACES: FR-DEV-3 | FR-DEV-5 - /// Sampling something that is already neutral corrects nothing, and says - /// so by leaving the stack alone. - /// - /// The failure this guards is a picker that lands a ten-thousandth off - /// zero: the photograph would come back marked modified, an undo step - /// would appear for a correction of nothing, and the sidecar would gain a - /// temperature the photographer never chose. The solve rounds to the - /// precision the control is drawn at, which is what makes "no correction" - /// representable at all. - #[test] - fn sampling_a_grey_that_is_already_grey_leaves_the_photograph_alone() { - let Some(ctx) = headless() else { return }; - let (mut session, _) = grey_session(&ctx); - let steps = session.history_rows().len(); - - assert!( - session.sample_neutral(0.5, 0.5), - "a flat grey frame is a usable sample" - ); - assert!( - session.is_neutral(), - "there was nothing to correct, so nothing was corrected" - ); - assert_eq!( - session.history_rows().len(), - steps, - "and a correction of nothing is not a step" - ); - } - - /// TRACES: FR-DEV-3 - /// The whole point of the picker, measured where the photographer sees - /// it: a cast grey on a *raw* frame, sampled, renders grey. - /// - /// On a raw frame and not a JPEG, because that is where it was wrong. The - /// white balance gains multiply camera RGB, before the body's matrix - /// turns it into sRGB; the probe was read *after* the matrix, and the - /// solve treated the two as the same space. On a body whose matrix mixes - /// the channels as much as a Canon's does, a slightly blue wall came back - /// tint −77 and the whole frame went green. A JPEG carries an identity - /// matrix, so the same test on one passed while the picker was broken. - #[test] - fn sampling_a_cast_grey_on_a_raw_frame_renders_it_grey() { - let Some(ctx) = headless() else { return }; - - // A Canon EOS 6D's D65 matrix (rows summing to one, as - // `neutral_stays_neutral_through_the_colour_matrix` requires) and a - // typical as-shot balance for it. - let cam_to_srgb = [ - 1.9125, -1.0587, 0.1461, // - -0.2249, 1.6466, -0.4217, // - 0.0099, -0.5093, 1.4994, - ]; - let as_shot = [1.9, 1.0, 1.7]; - // What the wall should look like once the camera's own balance is on: - // a warm cast, a little over half a stop between red and blue. - let balanced = [0.30f32, 0.25, 0.20]; - let sensor: Vec = (0..3) - .map(|c| (balanced[c] / as_shot[c] * 65535.0).round() as u16) - .collect(); - let size = 64u32; - let raw = RawImage { - width: size, - height: size, - data: sensor.repeat((size * size) as usize), - cfa_pattern: dr_decode::CfaPattern::Rggb, - black_level: [0; 4], - white_level: 65535, - wb_coeffs: [as_shot[0], as_shot[1], as_shot[2], 0.0], - color_matrix: Some(cam_to_srgb), - base_curve: dr_decode::BaseCurve::IDENTITY, - samples_per_pixel: 3, - profile: None, - make: String::new(), - model: String::new(), - crop: dr_decode::CropRect { - x: 0, - y: 0, - width: size, - height: size, - }, - }; - let mut session = - DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); - - let at = ((size / 2) * size + size / 2) as usize * 4; - let before = read_back(&ctx, &session.render(size, size).expect("render")); - let cast = |px: &[u8]| px.iter().max().unwrap() - px.iter().min().unwrap(); - assert!( - cast(&before[at..at + 3]) > 20, - "the premise: the wall renders with a cast, {:?}", - &before[at..at + 3] - ); - - assert!( - session.sample_neutral(0.5, 0.5), - "a mid-grey is a usable sample" - ); - - let after = read_back(&ctx, &session.render(size, size).expect("render")); - let px = &after[at..at + 3]; - assert!( - cast(px) <= 3, - "the sampled point should render neutral, got {px:?} with {:?}", - session - .rows() - .iter() - .filter(|r| r.value != r.default_value) - .map(|r| (r.param_label.to_string(), r.value)) - .collect::>() - ); - } - - /// TRACES: FR-DEV-3 - /// The picker reads a patch, not a photosite. - /// - /// A frame whose pixels alternate warm and cool grey, averaging to a - /// neutral: a point sample lands on one or the other and swings the - /// controls hard one way, which is what two white boxes on the same wall - /// answering +37 and −50 looked like. Averaged, there is nothing to - /// correct, and the graph says so. - #[test] - fn sampling_averages_a_patch_rather_than_reading_one_photosite() { - let Some(ctx) = headless() else { return }; - // Large enough that the patch — a couple of percent of the frame — - // holds many sensor pixels; on a 64px frame it would hold one, and - // the test would be asserting about interpolation instead. - let size = 1536u32; - let warm = [0.30f32, 0.25, 0.20]; - let cool = [0.20f32, 0.25, 0.30]; - let mut data = Vec::with_capacity((size * size * 3) as usize); - for i in 0..(size * size) as usize { - let p = if i % 2 == 0 { warm } else { cool }; - data.extend(p.iter().map(|c| (c * 65535.0).round() as u16)); - } - let raw = RawImage { - width: size, - height: size, - data, - cfa_pattern: dr_decode::CfaPattern::Rggb, - black_level: [0; 4], - white_level: 65535, - wb_coeffs: [1.0, 1.0, 1.0, 0.0], - color_matrix: None, - base_curve: dr_decode::BaseCurve::IDENTITY, - samples_per_pixel: 3, - profile: None, - make: String::new(), - model: String::new(), - crop: dr_decode::CropRect { - x: 0, - y: 0, - width: size, - height: size, - }, - }; - let mut session = - DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); - - assert!( - session.sample_neutral(0.5, 0.5), - "a mid-grey patch is usable" - ); - let moved: Vec<_> = session - .rows() - .iter() - .filter(|r| r.value != r.default_value) - .map(|r| (r.param_label.to_string(), r.value)) - .collect(); - assert!( - moved.iter().all(|(_, v)| v.abs() <= 2.0), - "the patch averages neutral, so nothing should move far: {moved:?}" - ); - } - - /// TRACES: FR-DEV-3 - /// A blown highlight is refused, the way black is. - /// - /// Sensor white is not a colour: every channel stopped counting, so the - /// ratio between them is the as-shot multipliers and nothing about the - /// scene. Sampling the overcast sky on a Canon 6D frame drove tint to - /// -100 and temperature to -15 for a patch the canvas showed as pure - /// white, which is the picker being wrong rather than the point being a - /// poor choice. Refused, nothing moves and no step is taken. - #[test] - fn sampling_a_blown_highlight_moves_nothing() { - let Some(ctx) = headless() else { return }; - let size = 16u32; - let raw = RawImage { - width: size, - height: size, - data: vec![65535; (size * size * 3) as usize], - cfa_pattern: dr_decode::CfaPattern::Rggb, - black_level: [0; 4], - white_level: 65535, - wb_coeffs: [1.9, 1.0, 1.7, 0.0], - color_matrix: None, - base_curve: dr_decode::BaseCurve::IDENTITY, - samples_per_pixel: 3, - profile: None, - make: String::new(), - model: String::new(), - crop: dr_decode::CropRect { - x: 0, - y: 0, - width: size, - height: size, - }, - }; - let mut session = - DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); - let steps = session.history_rows().len(); - - assert!( - !session.sample_neutral(0.5, 0.5), - "a clipped photosite has no balance in it" - ); - assert!(session.is_neutral(), "and so nothing was corrected"); - assert_eq!(session.history_rows().len(), steps); - } - - /// TRACES: FR-DEV-7 | FR-DEV-5 - /// A held comparison hands the edit straight back. - /// - /// This is the whole difference between comparing and the - /// undo-look-redo that had to stand in for it. Two steps on the stack, at - /// the moment a photographer is least sure of what they are doing, was - /// the price of looking — and this asserts the price is now nothing: - /// the same parameters, the same history, still modified. - #[test] - fn showing_the_original_leaves_the_edit_exactly_as_it_was() { - let Some(ctx) = headless() else { return }; - let (mut session, _) = grey_session(&ctx); - - // The first control that actually moves the picture — addressed by - // index, so this still names no operation (FR-DEV-3a). - // - // Deliberately not row zero. `EditGraph::capabilities` puts the lens - // corrections first, matching where they sit in the shader, and those - // carry profile coefficients rather than parameters: with no profile - // loaded, driving one to its maximum leaves the graph neutral. Taking - // the first row blindly made the premise below fail for a reason that - // has nothing to do with what is being asserted. - let rows = session.rows(); - let row = rows - .iter() - .find(|row| { - session.set_param(row.op_index, row.param_index, row.maximum); - !session.is_neutral() - }) - .expect("some control in the panel moves the picture") - .clone(); - let _ = row; - - let edit = session.copy_settings(); - let steps = session.history_rows().len(); - assert!(!session.is_neutral(), "the premise: there is an edit"); - - session - .render_original(64, 64) - .expect("render the original"); - - assert_eq!( - session.copy_settings(), - edit, - "every parameter comes back where it was" - ); - assert_eq!( - session.history_rows().len(), - steps, - "looking is not a step to take back" - ); - assert!( - !session.is_neutral(), - "and the photograph is still modified" - ); - } - - /// TRACES: FR-DEV-5 - /// A snapshot is a state the photographer named: taking one changes - /// nothing, going back to it is one step, and undo takes the whole of - /// that step back. - #[test] - fn a_snapshot_is_restored_as_one_step_and_undone_as_one() { - let Some(ctx) = headless() else { return }; - let (mut session, _) = grey_session(&ctx); - - let rows = session.rows(); - let row = rows - .iter() - .find(|row| { - session.set_param(row.op_index, row.param_index, row.maximum); - !session.is_neutral() - }) - .expect("some control in the panel moves the picture") - .clone(); - let liked = session.copy_settings(); - let steps_before = session.history_rows().len(); - - let id = session.take_snapshot("Liked this"); - assert_eq!(session.snapshots().len(), 1); - assert_eq!(session.snapshots()[0].name, "Liked this"); - assert_eq!( - session.history_rows().len(), - steps_before, - "naming a state is not a change to the photograph" - ); - - // Move on, then go back. - session.set_param(row.op_index, row.param_index, row.minimum); - let moved_on = session.copy_settings(); - assert_ne!(moved_on, liked, "the premise: the edit has moved"); - let steps_moved = session.history_rows().len(); - - assert!(session.restore_snapshot(&id)); - assert_eq!(session.copy_settings(), liked, "back to the named state"); - assert_eq!( - session.history_rows().len(), - steps_moved + 1, - "restoring is one step" - ); - - assert!(session.undo()); - assert_eq!( - session.copy_settings(), - moved_on, - "and undo takes the whole restore back" - ); - - // A name nobody typed is numbered rather than blank. - session.take_snapshot(" "); - assert_eq!(session.snapshots()[1].name, "Snapshot 2"); - - session.delete_snapshot(&id); - assert_eq!(session.snapshots().len(), 1); - assert_eq!(session.removed_snapshots(), [id.as_str()]); - } - - /// TRACES: FR-DEV-7 | FR-DEV-5 - /// Holding a snapshot against the edit is the same bargain as holding - /// the original: the picture changes, and nothing else does. - #[test] - fn comparing_against_a_snapshot_leaves_the_edit_exactly_as_it_was() { - let Some(ctx) = headless() else { return }; - let (mut session, _) = grey_session(&ctx); - - let rows = session.rows(); - let row = rows - .iter() - .find(|row| { - session.set_param(row.op_index, row.param_index, row.maximum); - !session.is_neutral() - }) - .expect("some control in the panel moves the picture") - .clone(); - let id = session.take_snapshot("Bright"); - session.set_param(row.op_index, row.param_index, row.minimum); - let edit = session.copy_settings(); - let steps = session.history_rows().len(); - - assert!(session.compare_snapshot(Some(&id)), "the hold began"); - assert!( - !session.compare_snapshot(Some(&id)), - "a repeat of the same hold is not a change" - ); - assert_eq!(session.compared_snapshot(), Some(id.as_str())); - session - .render_compared(64, 64) - .expect("render the snapshot"); - - assert_eq!(session.copy_settings(), edit, "every parameter comes back"); - assert_eq!(session.history_rows().len(), steps, "looking is not a step"); - - assert!(session.compare_snapshot(None), "and letting go is one"); - assert!(session.compared_snapshot().is_none()); - assert!( - !session.compare_snapshot(Some("nothing-by-this-name")), - "a snapshot that does not exist cannot be held" - ); - } - - /// TRACES: FR-DEV-3 | FR-CAT-8 - /// Reopening an edited photograph renders its subject mask, with no model. - /// - /// This is the failure the stored raster exists for. `apply_version` is - /// exactly what opening a photograph from the library does with the - /// sidecar it fetched, and until the coverage went into the file the layer - /// resolved to nothing every time. - #[test] - fn a_stored_subject_mask_renders_with_no_model_run() { - let Some(ctx) = headless() else { return }; - let (mut session, before) = grey_session(&ctx); - - let proxy = session.mask_raster_size(); - session.apply_version(&version_with_a_subject(proxy, true)); - let after = read_back(&ctx, &session.render(64, 64).expect("render")); - - let at = |px: &[u8], x: usize, y: usize| px[(y * 64 + x) * 4]; - assert!( - at(&after, 16, 32) > at(&before, 16, 32) + 20, - "the covered half must brighten: {} against {}", - at(&after, 16, 32), - at(&before, 16, 32) - ); - // And only that half. A mask that failed the other way — covering - // everything — would pass the assertion above and is the louder bug. - assert_eq!( - at(&after, 56, 32), - at(&before, 56, 32), - "the uncovered half must not move" - ); - } - - /// The control for the test above: the same edit with the raster left out - /// is the behaviour every build had before this, which is no mask at all. - /// - /// Worth pinning down in both directions. It is what a sidecar written by - /// an older build looks like, and reading one must go on being harmless — - /// the layer needs the model run, exactly as it always did, rather than - /// rendering as an empty mask or as the whole frame. - #[test] - fn a_subject_mask_with_no_stored_coverage_still_needs_the_model() { - let Some(ctx) = headless() else { return }; - let (mut session, before) = grey_session(&ctx); - - let proxy = session.mask_raster_size(); - session.apply_version(&version_with_a_subject(proxy, false)); - let after = read_back(&ctx, &session.render(64, 64).expect("render")); - - assert_eq!( - after, before, - "with no coverage the layer contributes nothing" - ); - } - - /// TRACES: FR-EXP-9 | FR-CAT-8 - /// A batch export renders the stored mask too. - /// - /// `export::render_from_library` fetches the RAW and the sidecar, opens a - /// session, applies the version and calls `render_for_export` — and it - /// runs no model on the way, so before the coverage was stored a batch - /// wrote out files with the photographer's local adjustments missing, over - /// a log warning nobody was reading. These two lines are that path with - /// the fetch taken out. - #[test] - fn an_export_renders_a_stored_subject_mask() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) - .expect("session"); - - let plain = session - .render_for_export(dr_types::ColourSpace::Srgb) - .expect("export"); - - let proxy = session.mask_raster_size(); - session.apply_version(&version_with_a_subject(proxy, true)); - let masked = session - .render_for_export(dr_types::ColourSpace::Srgb) - .expect("export"); - - let at = |f: &dr_export::Frame, x: usize, y: usize| f.rgba[(y * 64 + x) * 4]; - assert!( - at(&masked, 16, 32) > at(&plain, 16, 32) + 20, - "the exported file must carry the local adjustment: {} against {}", - at(&masked, 16, 32), - at(&plain, 16, 32) - ); - assert_eq!( - at(&masked, 56, 32), - at(&plain, 56, 32), - "and only where the mask covers" - ); - } - - /// What a session hands the sidecar writer when no model has run. - /// - /// Untouched, and that is the whole of it: a photograph opened, looked at - /// and closed must not have the coverage its own sidecar gave it stripped - /// out on the way past. Saving is automatic, so a stack that lost the - /// raster here would lose it on disk within seconds of being opened. - #[test] - fn saving_without_a_model_keeps_the_coverage_that_was_loaded() { - let Some(ctx) = headless() else { return }; - let (mut session, _) = grey_session(&ctx); - - let proxy = session.mask_raster_size(); - session.apply_version(&version_with_a_subject(proxy, true)); - let stored = session.masks_for_storage(); - - assert_eq!(stored.len(), 1); - assert!( - stored.layers()[0].base().coverage.is_some(), - "the raster the sidecar gave us must go back to the sidecar" - ); - } - - /// A session holding a segmentation with one instance over the left half. - /// - /// Built rather than detected. What these tests need is coverage of a - /// *known* shape, so that "the stored raster renders what the model's did" - /// is a comparison rather than a hope — and asking a real run what it - /// happened to find in a synthetic frame would make the assertion depend - /// on the weights. `segmented_session` above is the one that runs a model, - /// and it goes on doing so. - fn session_with_a_left_half_subject(ctx: &GpuContext) -> DevelopSession { - let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); - let mut session = - DevelopSession::open_rgb(ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) - .expect("session"); - - let (pw, ph) = session.mask_raster_size(); - let (pw, ph) = (pw as usize, ph as usize); - let mut mask = vec![0u8; pw * ph]; - for y in 0..ph { - for x in 0..pw / 2 { - mask[y * pw + x] = 255; - } - } - - session.segmentation = Some(segmentation::Segmentation::for_test( - vec![segmentation::InstanceSummary { - class_name: "dog".into(), - score: 0.9, - mask, - bbox: (0.0, 0.0, (pw / 2) as f32, ph as f32), - }], - Vec::new(), - 0xfeed, - (pw, ph), - )); - session.subjects = None; - session.subject_key = 0; - session - } - - /// TRACES: FR-DEV-3 | FR-CAT-8 - /// The whole claim, end to end: what is stored renders what was rendered. - /// - /// A session with a model's coverage in hand draws the mask; the stack it - /// hands the sidecar writer goes through the file and into a session with - /// no model at all; and the two frames must be the same. Anything weaker - /// — that the coverage is present, that it round-trips as bytes — would - /// still pass if the raster came back at the wrong scale, upside down, or - /// a threshold out. - #[test] - fn a_shown_mask_is_only_shown_while_masking() { - let Some(ctx) = headless() else { return }; - let mut s = session_with_a_left_half_subject(&ctx); - let id = s.add_subject_mask(0).expect("a subject layer"); - s.set_overlay(true); - s.set_mask_shown(&id, true); - assert!(s.any_mask_shown(), "lit, in Local mode"); - - // Leaving the mode — what `on_mode_picked` does for Photo and Spots. - s.set_overlay(false); - assert!( - !s.any_mask_shown(), - "the tint belongs to the mode, not to the photograph" - ); - assert!(s.mask_shown(&id), "the eye itself is remembered"); - - s.set_overlay(true); - assert!(s.any_mask_shown(), "and is lit again on return"); - } - - #[test] - fn a_stored_mask_renders_exactly_what_the_model_rendered() { - let Some(ctx) = headless() else { return }; - - let mut live = session_with_a_left_half_subject(&ctx); - let id = live.add_subject_mask(0).expect("a subject layer"); - // TRACES: FR-DEV-19c - // Making a mask opens its eye (`show_new_mask`), and this test is - // about the pixels the *edit* produces. Closed here rather than left - // open, and the asymmetry is the point rather than an inconvenience: - // the reveal is how somebody is looking at a photograph, so a session - // that has just made a layer legitimately draws a frame that a session - // which read the same layer out of a file does not. Both of those are - // correct, and only one of them is what a stored raster has to - // reproduce. - live.set_mask_shown(&id, false); - live.graph - .masks_mut() - .get_mut(&id) - .expect("the layer") - .set_param("exposure", ParamId("exposure"), 2.0); - let with_model = read_back(&ctx, &live.render(64, 64).expect("render")); - - // Through the sidecar, the way `presets::save_open_edit` does it: the - // graph's own parameters, with the stack that carries the coverage - // substituted for the one the graph is holding. - let mut version = dr_pipeline::Version::from_graph("default", "Default", &live.graph); - version.masks = live.masks_for_storage(); - let mut sidecar = dr_pipeline::Sidecar::new(); - sidecar.put(version); - let text = sidecar.to_text(); - let parsed = dr_pipeline::Sidecar::parse(&text).expect("reparse"); - - let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); - let mut reopened = - DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) - .expect("session"); - assert!( - !reopened.has_segmentation(), - "the point: nothing has run a model in this session" - ); - reopened.apply_version(parsed.default_version().expect("a version")); - // TRACES: FR-DEV-19c - // And a sidecar carries no viewing state: a photograph reopened is not - // reopened with its masks tinted red. Checked here because this is the - // one test that puts an edit through a file and renders both ends, so - // it is where the property would first go wrong. - assert!( - !reopened.any_mask_shown(), - "restoring an edit must not open an eye" - ); - let from_the_file = read_back(&ctx, &reopened.render(64, 64).expect("render")); - - assert_eq!( - from_the_file, with_model, - "the reopened frame must be the frame the model produced" - ); - // And that both are actually a mask rather than both being nothing. - let at = |px: &[u8], x: usize| px[(32 * 64 + x) * 4]; - assert!( - at(&with_model, 16) > at(&with_model, 56) + 20, - "the premise: the live render really is masked ({} against {})", - at(&with_model, 16), - at(&with_model, 56) - ); - } - - /// And what a session hands over when a model *has* run: the coverage - /// folded in, at the proxy the distance field is measured in. - /// - /// The encoding happens here rather than where the field is built because - /// that runs on a drag — see `masks_for_storage`. - #[test] - fn saving_after_a_model_run_folds_the_coverage_in() { - let Some(ctx) = headless() else { return }; - let mut session = session_with_a_left_half_subject(&ctx); - let id = session.add_subject_mask(0).expect("a subject layer"); - - let stored = session.masks_for_storage(); - let coverage = stored - .get(&id) - .expect("the layer is in the stack") - .base() - .coverage - .as_ref() - .expect("a subject the model found has coverage to store"); - - let (pw, ph) = session.mask_raster_size(); - assert_eq!( - (coverage.width(), coverage.height()), - (pw as usize, ph as usize) - ); - let decoded = coverage.decode(); - assert_eq!(decoded[32 * pw as usize + 8], 255, "inside the subject"); - assert_eq!(decoded[32 * pw as usize + 56], 0, "outside it"); - assert!( - coverage.encoded_len() < 512, - "a half-frame mask is a handful of runs, not {} bytes", - coverage.encoded_len() - ); - } - - /// A layer whose coverage came from the file must still take the model's - /// once a model runs — the live answer is the only one that responds to - /// the refine control, and a run in this sitting is newer than a file. - #[test] - fn a_model_run_takes_precedence_over_what_was_stored() { - let Some(ctx) = headless() else { return }; - let mut session = session_with_a_left_half_subject(&ctx); - - // Stored coverage saying the *right* half, against a segmentation - // saying the left. - let (pw, ph) = session.mask_raster_size(); - let (pw, ph) = (pw as usize, ph as usize); - let mut mirrored = vec![0u8; pw * ph]; - for y in 0..ph { - for x in pw / 2..pw { - mirrored[y * pw + x] = 255; - } - } - - let id = session.add_subject_mask(0).expect("a subject layer"); - { - let layer = session.graph.masks_mut().get_mut(&id).expect("the layer"); - layer.set_param("exposure", ParamId("exposure"), 2.0); - layer.base_mut().coverage = Some(Arc::new( - dr_pipeline::Coverage::encode( - &mirrored, - pw, - ph, - dr_pipeline::coverage::RENDERED_LEVELS, - ) - .expect("encode"), - )); - } - - let frame = read_back(&ctx, &session.render(64, 64).expect("render")); - let at = |x: usize| frame[(32 * 64 + x) * 4]; - assert!( - at(16) > at(56) + 20, - "the model's left half must win over the file's right half: \ - {} against {}", - at(16), - at(56) - ); - } - - /// TRACES: FR-DEV-3 - /// Multi-select: one slider, applied to every selected layer. - /// - /// `toggle_active_mask` builds the selection a control-click makes, and - /// `set_param`/`reset_op` are what a drag and a reset call — this pins - /// down that both fan out to every layer in it rather than only the - /// first, which is the whole point of selecting more than one. - #[test] - fn a_slider_moved_with_two_layers_selected_moves_both() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) - .expect("session"); - - let a = session.add_gradient_mask(true).expect("first gradient"); - let b = session.add_gradient_mask(false).expect("second gradient"); - // Adding `b` selected it alone — build the multi-selection a - // control-click would, starting from that single-layer state. - session.toggle_active_mask(&a); - assert_eq!(session.active_masks(), [b.clone(), a.clone()].as_slice()); - - assert!( - !session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b), - "neither layer has been touched yet" - ); - - let row = session.rows()[0].clone(); - session.set_param(row.op_index, row.param_index, row.maximum); - - assert!( - session.mask_is_adjusted(&a) && session.mask_is_adjusted(&b), - "one slider, both layers selected, both layers must show the edit" - ); - - // And a reset walks the same set. - session.reset_op(row.op_index); - assert!( - !session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b), - "resetting with both selected must clear both, not just the one \ - the panel happens to read values from" - ); - } - - /// TRACES: FR-DEV-3 - /// A refine crop's mask, pasted back onto the proxy grid it replaces, - /// must land exactly where the crop was — not shifted by the origin, not - /// scaled onto the wrong footprint. - #[test] - fn a_refined_mask_pastes_back_at_the_crops_own_position() { - // A crop entirely on (1.0), covering proxy pixels 2..6 in x and - // 2..6 in y of an 8x8 proxy — everywhere inside that box must read - // back as fully covered, everywhere outside as untouched. - let crop = vec![1.0f32; 4 * 4]; - let mask = paste_into_proxy(&crop, 4, 4, (8, 8), (2.0, 2.0), (4.0, 4.0)); - - assert_eq!(mask[2 * 8 + 2], 255, "top-left corner of the box"); - assert_eq!(mask[5 * 8 + 5], 255, "bottom-right corner of the box"); - assert_eq!(mask[0], 0, "outside the box, untouched"); - assert_eq!(mask[7 * 8 + 7], 0, "outside the box, untouched"); - } - - #[test] - fn bilinear_sample_averages_its_four_neighbours() { - // Two rows, black then white: the exact midpoint reads as grey. - let mask = [0.0f32, 0.0, 1.0, 1.0]; - let v = bilinear_sample(&mask, 2, 2, 0.5, 0.5); - assert!( - (v - 0.5).abs() < 1e-6, - "expected the midpoint grey, got {v}" - ); - } - - /// A control-click twice — once to add, once to remove — is a no-op on - /// the selection, which is the sanity check for `toggle_active_mask` - /// itself before trusting anything built on it. - #[test] - fn toggling_a_layer_twice_returns_to_the_starting_selection() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) - .expect("session"); - - let a = session.add_gradient_mask(true).expect("gradient"); - assert_eq!(session.active_masks(), [a.clone()].as_slice()); - - session.toggle_active_mask(&a); - assert!(session.active_masks().is_empty(), "removed by the toggle"); - - session.toggle_active_mask(&a); - assert_eq!(session.active_masks(), [a.clone()].as_slice(), "added back"); - } - - /// TRACES: FR-DEV-3 - /// The mask array and the segmentation proxy are the same size on purpose. - /// - /// They have to be: a subject layer's distance field is built at the - /// segmentation's proxy resolution and sampled against the array, so if the - /// two ever diverged a subject mask would be drawn at the wrong scale — - /// a mask that is confidently in the wrong place, which is worse than none. - /// - /// It used to hold because the array's size was *read off* the - /// segmentation, which also made a gradient wait for a model it does not - /// use. Deriving both from the photograph keeps the agreement and drops the - /// dependency, and this is what stops the agreement being an accident. - #[test] - fn the_mask_array_is_the_size_the_segmentation_will_use() { - let Some(ctx) = headless() else { return }; - - let rgba: Vec = (0..100 * 100) - .flat_map(|_| [128u8, 128, 128, 255]) - .collect(); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL) - .expect("session"); - - let before = session.mask_raster_size(); - if session - .segment(&crate::segmentation::Options::default()) - .is_err() - { - eprintln!("no model; skipping"); - return; - } - assert_eq!( - before, - session.mask_raster_size(), - "a mask rasterised before the model ran must not move when it does" - ); - } - - #[test] - fn an_unzoomed_overlay_shows_the_whole_frame() { - let Some(ctx) = headless() else { return }; - let Some(session) = segmented_session(&ctx) else { - eprintln!("no model; skipping"); - return; - }; - - let (x, y, w, h) = session.overlay_clip(); - assert_eq!((x, y), (0, 0)); - assert!(w > 1 && h > 1, "the whole proxy: {w}x{h}"); - } - - /// The bug this exists for: zooming must narrow the clip, or the overlay - /// keeps showing the whole picture at frame size while the canvas shows a - /// detail of it. - #[test] - fn zooming_narrows_the_overlay_to_what_is_visible() { - let Some(ctx) = headless() else { return }; - let Some(mut session) = segmented_session(&ctx) else { - eprintln!("no model; skipping"); - return; - }; - - let (_, _, full_w, full_h) = session.overlay_clip(); - session.zoom_about(4.0, 0.5, 0.5); - let (_, _, zoomed_w, zoomed_h) = session.overlay_clip(); - - assert!( - zoomed_w < full_w && zoomed_h < full_h, - "zoomed in, the overlay should show less: {zoomed_w}x{zoomed_h} \ - against {full_w}x{full_h}" - ); - } - - #[test] - fn panning_moves_the_overlay_with_the_photograph() { - let Some(ctx) = headless() else { return }; - let Some(mut session) = segmented_session(&ctx) else { - eprintln!("no model; skipping"); - return; - }; - - session.zoom_about(4.0, 0.5, 0.5); - let (before_x, _, _, _) = session.overlay_clip(); - session.pan_by(0.3, 0.0); - let (after_x, _, _, _) = session.overlay_clip(); - - assert!( - after_x > before_x, - "panning right moves the visible window right: {before_x} then {after_x}" - ); - } - - #[test] - fn cropping_narrows_the_overlay_too() { - let Some(ctx) = headless() else { return }; - let Some(mut session) = segmented_session(&ctx) else { - eprintln!("no model; skipping"); - return; - }; - - let (_, _, full_w, _) = session.overlay_clip(); - session.set_crop(dr_pipeline::CropRect { - x: 0.25, - y: 0.25, - width: 0.5, - height: 0.5, - }); - let (x, y, w, _) = session.overlay_clip(); - - assert!(w < full_w, "a half-width crop shows half the overlay"); - assert!(x > 0 && y > 0, "and it starts inside the frame"); - } - - /// Nothing segmented means no overlay, and no rectangle a caller might - /// divide by. - #[test] - fn no_segmentation_means_no_clip() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); - let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) - .expect("session"); - assert_eq!(session.overlay_clip(), (0, 0, 0, 0)); - } - - /// TRACES: FR-DSP-1 | AC-8 - #[test] - fn the_displayed_frame_is_a_texture_and_not_a_pixel_buffer() { - // The acceptance criterion itself, asserted from the side that would - // notice it regressing. `to_rgba8` returning `Some` would mean the - // frame had come back through system memory to be looked at, which is - // the ~7 ms per frame at 4K that ARCH §6.1 forbids; `to_wgpu_29_texture` - // returning `Some` means the compositor got the texture where it lay. - // - // Note this passes without a display: the import is a wrapper, and it - // is the *compositor* adopting the device that needs a screen. What - // cannot be proved here is that the picture arrives; what can be - // proved is that no copy was made on the way. - let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { - log::warn!("no GPU adapter; skipping"); - return; - }; - - let rgba = vec![128u8; 32 * 32 * 4]; - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL) - .expect("session"); - let frame = session.render(32, 32).expect("render"); - - assert!( - frame.to_rgba8().is_none(), - "the canvas has CPU pixels, so something copied them there" - ); - let texture = frame - .to_wgpu_29_texture() - .expect("the canvas is neither a texture nor a pixel buffer"); - assert_eq!((texture.width(), texture.height()), (32, 32)); - } - - /// TRACES: FR-DSP-1 | AC-8 - #[test] - fn consecutive_frames_look_different_to_the_property_system() { - // The catch that comes free with handing over a texture instead of a - // buffer. Slint repaints when the image property *changes*, and it - // decides that with `PartialEq` — which for two images over one - // `wgpu::Texture` says "unchanged". A pass that reused a single target - // would therefore render every slider move correctly and show none of - // them. - // - // `AdjustPass` alternates between two targets to prevent it. This - // asserts the consequence in the terms Slint actually uses, so it - // would still catch the regression if the mechanism were replaced. - let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { - log::warn!("no GPU adapter; skipping"); - return; - }; - - let rgba = vec![128u8; 32 * 32 * 4]; - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL) - .expect("session"); - - let first = session.render(32, 32).expect("first render"); - let second = session.render(32, 32).expect("second render"); - assert_ne!( - first, second, - "the canvas property would not change, so the frame would never be shown" - ); - } - - /// The whole scroll-to-zoom path, end to end, in the order the user drives - /// it: show the image fitted, *then* turn the wheel. - /// - /// The lower layers each had zoom tests and each passed while this was - /// broken, because every one of them set a view before its first render. - /// That ordering hid the bug — a neutral framing compiles a prologue that - /// never reads the crop rect, and while zoom was absent from the structure - /// hash that pipeline stayed cached once zoomed. The session reported the - /// new zoom, the uniforms carried the new view, and the pixels never moved. - /// - /// So this asserts on the rendered pixels rather than on `zoom()`: the - /// symptom was precisely that the state was right and the image was not. - #[test] - fn zooming_after_a_fitted_render_changes_the_pixels() { - let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { - log::warn!("no GPU adapter; skipping"); - return; - }; - - // A gradient, so any change in the sampled region moves the pixels. - let (w, h) = (64u32, 64u32); - let mut rgba = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h { - for x in 0..w { - rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]); - } - } - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL) - .expect("session"); - - let fitted = session.render(64, 64).expect("fitted render"); - session.zoom_about(4.0, 0.5, 0.5); - assert!(session.is_zoomed(), "the session did not register the zoom"); - let zoomed = session.render(64, 64).expect("zoomed render"); - - // Both images are still readable here because consecutive frames go to - // alternating textures; see `AdjustPass::targets`. Holding two frames - // at once would be meaningless against a single reused target. - let before = read_back(&ctx, &fitted); - let after = read_back(&ctx, &zoomed); - let differing = before - .iter() - .zip(after.iter()) - .filter(|(a, b)| a != b) - .count(); - - assert!( - differing > 0, - "zooming 4x after a fitted render produced identical pixels — the \ - view reached the session but not the shader" - ); - } - - #[test] - fn magnification_follows_the_source_resolution_and_not_the_zoom_factor() { - // What decides whether the canvas is filtered. The distinction this - // guards is the reason the interface cannot answer it from `zoom()` - // alone: the same 4x on a large source is still showing more source - // pixels than screen pixels, while on a small one it is already - // inventing values between them. - let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { - log::warn!("no GPU adapter; skipping"); - return; - }; - - // Bigger than the viewport it is shown in: `fit` scales it down, so - // every screen pixel still has several source pixels behind it. - let big = vec![128u8; (800 * 800 * 4) as usize]; - let mut session = - DevelopSession::open_rgb(&ctx, &big, 800, 800, dr_types::Orientation::NORMAL) - .expect("session"); - assert!( - !session.magnifies_source(200, 200), - "a downscaled image is not magnified" - ); - session.zoom_about(2.0, 0.5, 0.5); - assert!( - !session.magnifies_source(200, 200), - "2x on a 4x-downscaled source is still below 1:1" - ); - session.zoom_about(8.0, 0.5, 0.5); - assert!( - session.magnifies_source(200, 200), - "16x on a 4x-downscaled source magnifies and must not be filtered" - ); - - // Smaller than the viewport: `fit` refuses to upscale, so the render is - // 1:1 and unzoomed is exactly the boundary — not past it. - let small = vec![128u8; (100 * 100 * 4) as usize]; - let mut session = - DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL) - .expect("session"); - assert!( - !session.magnifies_source(800, 800), - "1:1 is the boundary, not past it — filtering must not flip on a \ - rounding error" - ); - session.zoom_about(2.0, 0.5, 0.5); - assert!( - session.magnifies_source(800, 800), - "any zoom past a 1:1 render magnifies" - ); - } - - #[test] - fn every_capability_becomes_exactly_one_row() { - // The UI shows what the pipeline offers — no more, and nothing - // dropped. Asserted against the chain rather than a literal count, - // so operations can be added without editing this, and so the test - // actually checks the correspondence rather than restating a number. - let graph = EditGraph::default_chain(); - let caps = graph.capabilities(); - let expected: usize = caps.iter().map(|c| c.params.len()).sum(); - - assert!(expected > 0, "the chain must expose some parameters"); - // Every (operation, parameter) pair must be reachable as a distinct - // row index; a collision would route two sliders to one parameter. - let mut seen = std::collections::HashSet::new(); - for (oi, cap) in caps.iter().enumerate() { - for (pi, _) in cap.params.iter().enumerate() { - assert!(seen.insert((oi, pi)), "duplicate row index"); - } - } - assert_eq!(seen.len(), expected); - } - - #[test] - fn each_operation_becomes_exactly_one_group() { - // The panel draws one section per group, and derives the boundary - // from `group_head` rather than from a flag the core supplies. Two - // heads for one operation would draw its heading twice; none would - // swallow the operation into the section above it. - let graph = EditGraph::default_chain(); - let caps = graph.capabilities(); - - // A row heads its group exactly when its own index equals its - // `group_head` — the same test `adjust.slint` makes. - let mut heads = 0; - for (i, row) in rows_of(&caps).iter().enumerate() { - if row.0 == i { - heads += 1; - } - } - // Every operation but framing, which has its own panel. - let generated = caps - .iter() - .filter(|c| c.id != dr_pipeline::framing::ID) - .count(); - assert_eq!(heads, generated); - } - - #[test] - fn regenerating_the_rows_leaves_unchanged_ones_equal() { - // **This is a dragging test wearing a data disguise.** - // - // `sync_rows` rewrites exactly the rows that compare unequal, and a - // rewritten row re-evaluates the repeater that a multi-parameter - // operation renders its parameters through — which rebuilds the items - // and destroys the `TouchArea` mid-gesture. So a row that differs from - // itself between two identical calls is a slider that takes the press, - // jumps once and then dies under the finger. - // - // It is asserted here rather than left to the eye because the failure - // is invisible in a still: every value is right, the panel looks - // perfect, and only a live drag on a *grouped* parameter shows it. - // `ModelRc` compares by identity, so any new model-valued field - // reintroduces this the moment it is built fresh per call. - let graph = EditGraph::default_chain(); - let caps = graph.capabilities(); - - let first = rows_from(&caps); - let second = rows_from(&caps); - assert_eq!(first.len(), second.len()); - - for (i, (a, b)) in first.iter().zip(second.iter()).enumerate() { - // A curve row is the one legitimate exception: its `points` model - // carries live coordinates, so it genuinely is rebuilt each call - // and `sync_rows` writes the values through the existing model - // instead of swapping it. Every other row must be stable here, at - // the source, rather than relying on a caller to repair it. - if a.kind == "curve" { - continue; - } - assert!( - a == b, - "row {i} ({}) differs from itself across two identical builds, \ - so every parameter event would rewrite it and break dragging", - a.param_label - ); - } - } - - #[test] - fn a_grouped_parameter_survives_a_neighbours_change() { - // The reported bug, at the level it actually occurred. Moving - // temperature flips `group_modified` on *both* of white balance's - // rows — that much is correct and intended. What must not happen is - // the untouched rows of *other* operations also coming back unequal, - // because rewriting a group's head row is what rebuilds the repeater - // holding the live drag. - let mut graph = EditGraph::default_chain(); - let before = rows_from(&graph.capabilities()); - - // Move the first parameter of the first multi-parameter operation, - // named by shape rather than by id so this keeps testing the property - // when the chain changes. - let caps = graph.capabilities(); - let group = caps - .iter() - .find(|c| c.params.len() > 1 && c.presentation.is_none()) - .expect("some operation has several plain parameters"); - let target = &group.params[0]; - graph.set_param(group.id, target.id, target.default + 1.0); - - let after = rows_from(&graph.capabilities()); - assert_eq!(before.len(), after.len()); - - // Curve rows excluded for the reason given in the test above: their - // points model is rebuilt by design and repaired in `sync_rows`. - let changed: Vec<&str> = before - .iter() - .zip(after.iter()) - .filter(|(a, b)| a != b && a.kind != "curve") - .map(|(a, _)| a.param_label.as_str()) - .collect(); - - // Its own group, and nothing beyond it. - assert_eq!( - changed.len(), - group.params.len(), - "moving one parameter should dirty only its own group's rows, \ - but these came back changed: {changed:?}" - ); - } - - /// TRACES: FR-DEV-3 | FR-DEV-3a - /// A canvas *sampler* adds an affordance; it does not take the sliders. - /// - /// The distinction the panel had been missing. `is_on_canvas` was read as - /// "and so the panel draws nothing", which is right for a crop and wrong - /// for an eyedropper — FR-DEV-3 asks for "temperature/tint, **and** - /// picker", and a picker that ate the two sliders would have traded one - /// control for another. Asserted against the chain rather than a literal, - /// so it keeps testing the property when the declaration moves. - #[test] - fn a_sampled_operation_keeps_the_sliders_it_writes() { - let graph = EditGraph::default_chain(); - let caps = graph.capabilities(); - - let (index, cap) = caps - .iter() - .enumerate() - .find(|(_, c)| { - c.presentation - .as_ref() - .is_some_and(|p| p.widgets.contains(&WidgetKind::WhitePoint)) - }) - .expect("some operation asks to be driven by a pixel"); - - let rows = rows_from(&caps); - let mine: Vec<_> = rows.iter().filter(|r| r.op_index == index as i32).collect(); - - assert_eq!( - mine.len(), - cap.params.len(), - "every parameter of a sampled operation still has its own control" - ); - assert!( - mine.iter().all(|r| r.group_samples), - "and the group's heading carries the picker" - ); - assert!( - rows.iter() - .filter(|r| r.op_index != index as i32) - .all(|r| !r.group_samples), - "no other group claims one" - ); - } - - #[test] - fn framing_is_not_generated_as_sliders() { - // `ComposePanel` presents crop, rotation, flips and straightening as - // the gestures they are. If the generic path emitted them too the - // sidebar would carry both — including four "Crop Left/Top/Width/ - // Height" sliders no one can compose a photograph with. - let graph = EditGraph::default_chain(); - let caps = graph.capabilities(); - - let framing = caps - .iter() - .position(|c| c.id == dr_pipeline::framing::ID) - .expect("the chain must still expose framing — the panel reads it"); - assert!(!caps[framing].params.is_empty()); - - // Checked against the real generator, and by *routing* rather than by - // counting: a row carries the capability index it writes back to, so - // "no row belongs to framing" is the property directly, and it cannot - // be satisfied accidentally by two miscounts cancelling out. - let rows = rows_from(&caps); - assert!( - rows.iter().all(|r| r.op_index as usize != framing), - "framing parameters leaked into the generated panel" - ); - // Every other operation still arrives, so the skip is specific rather - // than the panel having quietly stopped generating. - assert!(rows.len() > caps.len() - 1); - } - - #[test] - fn a_stage_is_yielded_to_the_canvas_by_what_it_declares_not_by_its_name() { - // The property that replaced `if op.id == framing::ID`. An invented - // stage preferring an on-canvas widget must be skipped on exactly the - // same terms — if this needs a name added anywhere to pass, the - // special case has grown back. - use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand}; - - let param = |id: &'static str| ParamCapability { - id: ParamId(id), - label: LocalizedKey("param.invented"), - kind: ParamKind::Scalar { - min: 0.0, - max: 1.0, - scale: dr_pipeline::Scale::Linear, - unit: Unit::None, - precision: 2, - }, - default: 0.0, - value: 0.0, - facet: None, - }; - - let on_canvas = OpCapability { - id: OpId("invented_mask"), - label: LocalizedKey("op.invented_mask"), - active: false, - presentation: Some(Presentation { - // Prefers a gradient handle; this frontend has none, so it - // falls back to the next entry, which the canvas does host. - widgets: vec![WidgetKind::GradientHandle, WidgetKind::CropOverlay], - demand: WidgetDemand { - two_dimensional: true, - precise_pointing: false, - }, - params: vec![ParamId("a"), ParamId("b")], - }), - params: vec![param("a"), param("b")], - attributes: vec![dr_pipeline::Attribute::Tone], - }; - - assert!(rows_from(&[on_canvas]).is_empty()); - } - - #[test] - fn a_group_spans_exactly_its_operations_rows() { - // `group_len` is how many rows the section reaches forward over. Too - // few silently drops controls off the bottom of a section; too many - // reads past the model and renders a neighbouring operation's - // parameters under the wrong heading. - let graph = EditGraph::default_chain(); - let caps = graph.capabilities(); - let rows = rows_of(&caps); - - for (i, row) in rows.iter().enumerate() { - let (head, len) = *row; - assert!(head <= i, "row {i} claims a head after itself"); - assert!( - head + len <= rows.len(), - "group at {head} reaches past the model" - ); - // Every row the group spans must agree it belongs to that group. - for (offset, spanned) in rows[head..head + len].iter().enumerate() { - let span = head + offset; - assert_eq!(spanned.0, head, "row {span} disagrees about its group"); - } - } - } - - #[test] - fn a_group_is_modified_when_any_of_its_parameters_is() { - // The dot on a collapsed section is the only thing saying an edit is - // hidden inside it, and it is derived here rather than asked of the - // core (ARCH §4.3a). - let mut graph = EditGraph::default_chain(); - let caps = graph.capabilities(); - // A fresh chain is at its defaults, so nothing is modified. - assert!( - caps.iter() - .all(|c| c.params.iter().all(|p| p.value == p.default)), - "a fresh chain must start neutral" - ); - - // Move one parameter of one operation off its default; only that - // operation's group may light up. - let (op_id, param_id, default) = caps - .iter() - .find_map(|c| { - c.params - .iter() - .find(|p| matches!(p.kind, ParamKind::Scalar { .. })) - .map(|p| (c.id, p.id, p.default)) - }) - .expect("the chain has a scalar parameter"); - graph.set_param(op_id, param_id, default + 1.0); - - let caps = graph.capabilities(); - let modified: Vec = caps - .iter() - .map(|c| c.params.iter().any(|p| p.value != p.default)) - .collect(); - assert_eq!( - modified.iter().filter(|m| **m).count(), - 1, - "one edit must mark exactly one group" - ); - - // And it goes out again when the value returns. - graph.set_param(op_id, param_id, default); - assert!( - graph - .capabilities() - .iter() - .all(|c| c.params.iter().all(|p| p.value == p.default)), - "returning a value to its default must clear the group" - ); - } - - /// `(group_head, group_len)` per row, flattened as - /// [`DevelopSession::rows`] flattens — without needing a GPU to build a - /// session. - /// - /// A widget hint only collapses an operation to one row when it is - /// *honoured*; `rows` falls back to sliders otherwise, and mirroring that - /// here is what keeps the test honest when a hint stops applying. - /// TRACES: FR-DEV-3a - /// A declared switch reaches the panel as a switch. - /// - /// `ParamKind::Bool` was in the core's closed enum, was mapped to the row - /// kind `"bool"` here, and had no control behind it in `adjust.slint` — - /// which meant a parameter declaring itself a switch was flattened into a - /// row that drew nothing at all. Nothing shipped had one until the lens - /// profile did, so the gap cost nothing and was invisible. - /// - /// This asserts the Rust half; the Slint half is the `if kind == "bool"` - /// branch in the control registry, which this cannot reach. - #[test] - fn a_switch_becomes_a_switch_row() { - use dr_pipeline::{LocalizedKey, ParamCapability}; - - let switched = OpCapability { - id: OpId("invented_switch"), - label: LocalizedKey("op.invented_switch"), - active: true, - presentation: None, - params: vec![ParamCapability { - id: ParamId("engaged"), - label: LocalizedKey("param.invented_switch.engaged"), - kind: ParamKind::Bool, - // On by default, which is the shape a correction the file - // itself asked for takes: off is the edit. - default: 1.0, - value: 0.0, - facet: None, - }], - attributes: vec![dr_pipeline::Attribute::Optics], - }; - - let rows = rows_from(&[switched]); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].kind, "bool"); - assert_eq!(rows[0].value, 0.0); - assert_eq!(rows[0].default_value, 1.0); - // A lone parameter is titled by its operation, so the box carries the - // name the withheld heading would have. - assert_eq!( - rows[0].param_label, - labels::resolve("op.invented_switch").as_str() - ); - assert!( - rows[0].group_modified, - "a switch turned off differs from its default like any other \ - parameter, and the group's marker has to say so" - ); - } - - /// TRACES: FR-DEV-3c - /// An operation this file has never heard of, appearing in the panel. - /// - /// The acceptance test requirements.md names for FR-DEV-3c: "a test - /// operation added to the registry appears in a generated panel with no - /// frontend change". Built as a capability rather than a real node so it - /// costs the pipeline nothing — what is being asserted is the mapping from - /// descriptor to control, and that mapping does not care whether a shader - /// exists behind it. - #[test] - fn an_operation_the_frontend_has_never_heard_of_gets_controls() { - use dr_pipeline::{LocalizedKey, ParamCapability}; - - let invented = OpCapability { - id: OpId("invented"), - label: LocalizedKey("op.invented"), - active: false, - presentation: None, - params: vec![ - ParamCapability { - id: ParamId("strength"), - label: LocalizedKey("param.invented.strength"), - kind: ParamKind::Scalar { - min: -100.0, - max: 100.0, - scale: dr_pipeline::Scale::Linear, - unit: Unit::Percent, - precision: 0, - }, - default: 0.0, - value: 25.0, - facet: None, - }, - ParamCapability { - id: ParamId("method"), - label: LocalizedKey("param.invented.method"), - kind: ParamKind::Enum { - variants: vec![ - LocalizedKey("param.invented.method.fast"), - LocalizedKey("param.invented.method.exact"), - ], - }, - default: 0.0, - value: 1.0, - facet: None, - }, - ], - attributes: vec![dr_pipeline::Attribute::Tone], - }; - - let rows = rows_from(&[invented]); - assert_eq!(rows.len(), 2, "each parameter should become one row"); - - // The scalar becomes a slider carrying its declared range and unit. - assert_eq!(rows[0].kind, "scalar"); - assert_eq!(rows[0].minimum, -100.0); - assert_eq!(rows[0].maximum, 100.0); - assert_eq!(rows[0].value, 25.0); - - // The enum becomes a choice, with its range spanning the variant - // indices and the variant names resolved for drawing. Nothing in this - // file names the operation or either parameter to make that happen. - assert_eq!(rows[1].kind, "enum"); - assert_eq!(rows[1].minimum, 0.0); - assert_eq!(rows[1].maximum, 1.0); - assert_eq!(rows[1].precision, 0); - assert_eq!(slint::Model::row_count(&rows[1].choices), 2); - // The value is the selected index, which is what the segmented control - // reads — an enum needs no separate selection field. - assert_eq!(rows[1].value, 1.0); - } - - #[test] - fn an_unimplemented_widget_falls_back_to_sliders_rather_than_vanishing() { - // ARCH §4.3a: falling off the end of the preference list is not an - // error. An operation asking only for a widget this frontend does not - // draw must still yield one control per parameter, or declaring a - // preference would be a way to make an edit unreachable. - use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand}; - - let wheel = OpCapability { - id: OpId("grading"), - label: LocalizedKey("op.grading"), - active: false, - presentation: Some(Presentation { - widgets: vec![WidgetKind::ColourWheel], - demand: WidgetDemand { - two_dimensional: true, - precise_pointing: false, - }, - params: vec![ParamId("hue"), ParamId("strength")], - }), - params: vec![ - ParamCapability { - id: ParamId("hue"), - label: LocalizedKey("param.grading.hue"), - kind: ParamKind::Scalar { - min: 0.0, - max: 360.0, - scale: dr_pipeline::Scale::Linear, - unit: Unit::None, - precision: 0, - }, - default: 0.0, - value: 0.0, - facet: None, - }, - ParamCapability { - id: ParamId("strength"), - label: LocalizedKey("param.grading.strength"), - kind: ParamKind::Scalar { - min: 0.0, - max: 1.0, - scale: dr_pipeline::Scale::Linear, - unit: Unit::None, - precision: 2, - }, - default: 0.0, - value: 0.0, - facet: None, - }, - ], - attributes: vec![dr_pipeline::Attribute::Tone], - }; - - assert!(!supported(WidgetKind::ColourWheel), "precondition"); - let rows = rows_from(&[wheel]); - assert_eq!(rows.len(), 2, "both parameters must remain reachable"); - assert!(rows.iter().all(|r| r.kind == "scalar")); - } - - /// Each generated row's `(group_head, group_len)`. - /// - /// Taken from the real generator rather than re-derived. This used to be a - /// hand-written simulation of `rows_from` — it walked the capabilities and - /// reproduced the grouping rules, including a copy of the framing skip — - /// which meant the tests below asserted against a second implementation - /// that had to be kept in step with the first by hand. It was not: giving - /// framing a presentation changed the real panel and the simulation - /// disagreed, which is how a passing test suite would have hidden the - /// change entirely. - fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> { - rows_from(caps) - .iter() - .map(|r| (r.group_head as usize, r.group_len as usize)) - .collect() - } - - #[test] - fn four_quarter_turns_return_a_crop_where_it_started() { - // The property that makes rotation safe to repeat: a user who turns - // past the orientation they wanted and keeps going must arrive back at - // the crop they had, not at a slowly drifting one. - let start = CropRect { - x: 0.1, - y: 0.2, - width: 0.3, - height: 0.4, - }; - let mut r = start; - for _ in 0..4 { - r = rotate_crop(r, 1); - } - assert!((r.x - start.x).abs() < 1e-5, "x drifted to {}", r.x); - assert!((r.y - start.y).abs() < 1e-5, "y drifted to {}", r.y); - assert!((r.width - start.width).abs() < 1e-5); - assert!((r.height - start.height).abs() < 1e-5); - } - - #[test] - fn a_quarter_turn_exchanges_a_crops_extents() { - // A portrait selection on a landscape frame must come out landscape. - // Were the extents left alone, the rect would keep its old shape while - // the frame changed to the other one, and the crop would spill off the - // photograph. - let r = rotate_crop( - CropRect { - x: 0.0, - y: 0.0, - width: 0.25, - height: 1.0, - }, - 1, - ); - assert!((r.width - 1.0).abs() < 1e-5, "width was {}", r.width); - assert!((r.height - 0.25).abs() < 1e-5, "height was {}", r.height); - } - - #[test] - fn rotating_a_crop_keeps_it_inside_the_frame() { - // Whatever the angle and wherever the rect, the result must still be a - // rect the pipeline can render: outside the unit square it would - // sample undefined area, and degenerate it is a zero-sized texture. - for turns in -5..=5 { - for rect in [ - CropRect { - x: 0.0, - y: 0.0, - width: 1.0, - height: 1.0, - }, - CropRect { - x: 0.7, - y: 0.8, - width: 0.3, - height: 0.2, - }, - CropRect { - x: 0.0, - y: 0.45, - width: 0.02, - height: 0.02, - }, - ] { - let r = rotate_crop(rect, turns); - assert!( - r.x >= 0.0 && r.y >= 0.0, - "{turns} turns of {rect:?} gave {r:?}" - ); - assert!( - r.x + r.width <= 1.0 + 1e-5 && r.y + r.height <= 1.0 + 1e-5, - "{turns} turns of {rect:?} left the frame: {r:?}" - ); - assert!( - r.width >= CropRect::MIN_EXTENT && r.height >= CropRect::MIN_EXTENT, - "{turns} turns of {rect:?} went degenerate: {r:?}" - ); - } - } - } - - #[test] - fn opposite_quarter_turns_cancel() { - // The rotate-left and rotate-right buttons must undo one another, or - // correcting an over-rotation would land somewhere new each time. - let start = CropRect { - x: 0.15, - y: 0.05, - width: 0.5, - height: 0.25, - }; - let there_and_back = rotate_crop(rotate_crop(start, 1), -1); - assert!((there_and_back.x - start.x).abs() < 1e-5); - assert!((there_and_back.y - start.y).abs() < 1e-5); - assert!((there_and_back.width - start.width).abs() < 1e-5); - assert!((there_and_back.height - start.height).abs() < 1e-5); - } - - #[test] - fn a_full_crop_survives_rotation_as_a_full_crop() { - // The common case: rotating an uncropped photograph must not quietly - // introduce a crop, which would shrink the exported image. - assert!(rotate_crop(CropRect::default(), 1).is_full()); - assert!(rotate_crop(CropRect::default(), -3).is_full()); - } - - #[test] - fn an_operation_without_facets_keeps_its_declared_order() { - // Every operation but the mixer. Reordering one of these would move - // Highlights below Shadows for no reason anybody could see in the - // code, so the stable sort has to be a no-op when nothing is faceted. - let graph = EditGraph::default_chain(); - for cap in graph.capabilities() { - if cap.params.iter().any(|p| p.facet.is_some()) { - continue; - } - let order = presentation_order(&cap.params); - assert_eq!( - order, - (0..cap.params.len()).collect::>(), - "{} was reordered", - cap.id - ); - } - } - - #[test] - fn faceted_parameters_are_stacked_one_run_per_aspect() { - // The panel names a run once and then draws its rows. That only works - // if a run is *contiguous*: the mixer declares band by band — red hue, - // red sat, red lum, orange hue — so shown in declaration order every - // single row would begin a new run, and the panel would draw - // thirty-six headings over thirty-six sliders. - let graph = EditGraph::default_chain(); - let cap = graph - .capabilities() - .into_iter() - .find(|c| c.params.iter().any(|p| p.facet.is_some())) - .expect("the chain has a faceted operation"); - - let mut seen: Vec<&str> = Vec::new(); - let mut previous: Option<&str> = None; - for i in presentation_order(&cap.params) { - let aspect = cap.params[i] - .facet - .as_ref() - .expect("this operation facets every parameter") - .aspect - .0; - if previous != Some(aspect) { - assert!( - !seen.contains(&aspect), - "{aspect} is split into two runs — a heading would be \ - drawn over each half" - ); - seen.push(aspect); - previous = Some(aspect); - } - } - assert!(seen.len() > 1, "the fixture must have several aspects"); - } - - #[test] - fn reordering_rows_does_not_move_where_a_change_is_routed() { - // The rows are stacked for reading; `param_index` still addresses the - // capability list. Were the two confused, dragging a band's Hue would - // silently write to whichever parameter happened to sit at that - // position — an edit landing on the wrong control, which reads as the - // renderer being broken rather than the panel. - let graph = EditGraph::default_chain(); - let cap = graph - .capabilities() - .into_iter() - .find(|c| c.params.iter().any(|p| p.facet.is_some())) - .expect("the chain has a faceted operation"); - - let mut order = presentation_order(&cap.params); - order.sort_unstable(); - assert_eq!( - order, - (0..cap.params.len()).collect::>(), - "the order must be a permutation: every parameter reachable from \ - exactly one row, and every row addressing a parameter that exists" - ); - } - - #[test] - fn every_faceted_parameter_resolves_to_a_band_name() { - // The bug this closes: `labels.rs` had no `param.mixer.*` entries, so - // all thirty-six keys fell through to a derived label that yields the - // bare channel name — twelve rows reading "Hue" with nothing saying - // which band. A row identified only by a swatch depends on this - // resolving, since the name is what a screen reader speaks and what - // anyone who cannot separate two squares by eye has to go on. - let graph = EditGraph::default_chain(); - for cap in graph.capabilities() { - for p in &cap.params { - let Some(facet) = &p.facet else { continue }; - let subject = labels::resolve(facet.subject.0); - let aspect = labels::resolve(facet.aspect.0); - assert!(!subject.is_empty(), "{} has no subject name", p.id); - assert!(!aspect.is_empty(), "{} has no aspect name", p.id); - // Not the channel name repeated: that is exactly the failure - // the catalogue entries were added to fix. - assert_ne!(subject, aspect, "{} is named after its channel", p.id); - } - } - } - - #[test] - fn unit_suffixes_come_from_the_descriptor() { - assert_eq!(unit_suffix(Unit::Stops), " EV"); - assert_eq!(unit_suffix(Unit::None), ""); - } - - #[test] - fn fitting_preserves_aspect_ratio() { - // A 3:2 image in a 16:9 window must letterbox, not stretch. - let (w, h) = fit(6000, 4000, 1600, 900); - assert_eq!(h, 900); - assert!( - ((w as f32 / h as f32) - 1.5).abs() < 0.01, - "got {w}x{h}, aspect {}", - w as f32 / h as f32 - ); - } - - #[test] - fn fitting_never_upscales_past_the_source() { - // Rendering a 400px image into a 4K window at 4K shades 25x the - // pixels for no additional detail. - let (w, h) = fit(400, 300, 3840, 2160); - assert_eq!((w, h), (400, 300)); - } - - #[test] - fn fitting_handles_a_degenerate_source() { - let (w, h) = fit(0, 0, 800, 600); - assert_eq!((w, h), (800, 600)); - } - - #[test] - fn fitting_is_bounded_by_the_narrow_axis() { - // A tall window on a wide image must be limited by width. - let (w, h) = fit(4000, 1000, 800, 4000); - assert_eq!(w, 800); - assert_eq!(h, 200); - } - - #[test] - fn the_curve_collapses_to_a_single_row() { - // Every point parameter — all four curves' worth — must appear as one - // curve control, not as forty sliders. Otherwise the widget and the - // sliders both render and the panel shows the same values twice. - let graph = EditGraph::default_chain(); - let curve_cap = graph - .capabilities() - .into_iter() - .find(|c| c.id == curve::ID) - .expect("the chain includes a tone curve"); - - assert_eq!( - curve_cap.params.len(), - curve::CHANNELS * curve::POINTS * 2, - "a master curve and one per colour channel" - ); - let presentation = curve_cap - .presentation - .as_ref() - .expect("the curve declares a widget"); - // Asked the way the panel asks it: the first preference this frontend - // implements, not a fixed single kind. - assert_eq!(presentation.choose(supported), Some(WidgetKind::ToneCurve)); - // Every parameter is owned by the widget, so none is left over to be - // rendered as a stray slider. - assert_eq!(presentation.params.len(), curve_cap.params.len()); - } - - #[test] - fn curve_point_parameters_are_contiguous() { - // The widget addresses points by offset from the first. Were they - // interleaved with anything else, dragging a point would write to - // the wrong parameter. - let graph = EditGraph::default_chain(); - let cap = graph - .capabilities() - .into_iter() - .find(|c| c.id == curve::ID) - .expect("tone curve present"); - let presentation = cap.presentation.as_ref().expect("declares a widget"); - - let base = cap - .params - .iter() - .position(|p| p.id == presentation.params[0]) - .expect("first point is a parameter"); - for (i, id) in presentation.params.iter().enumerate() { - assert_eq!( - cap.params[base + i].id, - *id, - "point parameter {i} is out of order" - ); - } - } - - /// The panel's whole knowledge of colour channels, asserted to be none. - /// - /// It groups the widget's parameters by the subject the *operation* put on - /// them and finds four curves; nothing below says "red", and an operation - /// that grew a fifth curve would arrive here on its own. - #[test] - fn a_curve_widget_offers_one_run_per_subject() { - let graph = EditGraph::default_chain(); - let cap = graph - .capabilities() - .into_iter() - .find(|c| c.id == curve::ID) - .expect("tone curve present"); - let presentation = cap.presentation.as_ref().expect("declares a widget"); - - let runs = curve_runs(&cap, presentation).expect("a curve-shaped operation"); - assert_eq!(runs.len(), curve::CHANNELS); - for (i, run) in runs.iter().enumerate() { - assert_eq!(run.len, curve::POINTS * 2, "run {i} is not five points"); - assert_eq!(run.base, i * curve::POINTS * 2); - assert!(run.subject.is_some(), "run {i} is unnamed"); - } - } - - /// TRACES: FR-DEV-3 - /// A lone parameter is titled by its operation, so several cannot collide. - /// - /// The panel draws no heading over a group of one, on the argument that a - /// lone control names itself. Three operations declare a single parameter - /// called `amount` — the name `ops/README.md` tells an author to reach for - /// first — and they reached the Detail group as three consecutive sliders - /// all reading "Amount", which is a panel a photographer cannot use. - /// - /// Asserted over the real chain rather than a fixture, because the failure - /// was a property of what is actually declared: a fixture would have to be - /// written to reproduce it and would then only prove itself. - /// TRACES: FR-DEV-3f - /// The film stock is offered in its own group and in "All", nowhere else. - /// - /// It is not a parameter, so it is not a row, so the filter that hides - /// every other control when a group is chosen never saw it: the picker sat - /// at the top of Light, of Colour and of Detail alike. Three places it does - /// not belong, and the one it does no more prominent than the rest. - /// - /// Asserted against whatever the operation actually declares rather than - /// against a named group, so a stock re-declared as something else moves - /// here on its own and this test still describes the rule. - #[test] - fn the_film_stock_is_offered_only_where_it_belongs() { - let Some(ctx) = headless() else { return }; - let (mut session, _) = grey_session(&ctx); - - let film = session - .graph - .capabilities() - .into_iter() - .find(|c| c.id == dr_pipeline::ops::film_sim::ID) - .expect("the film stock is in the chain"); - - session.set_active_tab(-1); - assert!( - session.film_in_group(), - "\"All\" hides nothing, so the stock is offered there" - ); - - for (i, (attribute, label)) in session.tabs().into_iter().enumerate() { - session.set_active_tab(i as i32); - let belongs = film.attributes.contains(&attribute); - assert_eq!( - session.film_in_group(), - belongs, - "the stock is offered in {label} but the operation does not claim it" - ); - } - } - - #[test] - fn a_lone_parameter_is_named_after_its_operation() { - let graph = EditGraph::default_chain(); - let caps = graph.capabilities(); - let rows = rows_filtered(&caps, |_| true, 0); - - for (i, cap) in caps.iter().enumerate() { - if cap.params.len() != 1 || cap.presentation.is_some() { - continue; - } - let Some(row) = rows.iter().find(|r| r.op_index as usize == i) else { - continue; - }; - assert_eq!( - row.param_label, - labels::resolve(cap.label.0).as_str(), - "a lone parameter must carry its operation's name, or the \ - heading the panel withheld takes the name with it" - ); - } - - // And the specific collision that started this: no two rows drawn - // without a heading may read the same. - let bare: Vec<_> = rows - .iter() - .filter(|r| r.group_len == 1) - .map(|r| r.param_label.to_string()) - .collect(); - let mut unique = bare.clone(); - unique.sort(); - unique.dedup(); - assert_eq!( - bare.len(), - unique.len(), - "two headingless rows share a label: {bare:?}" - ); - } - - #[test] - fn switching_curve_repoints_the_row() { - use slint::Model as _; - - // What a drag routes through. The row's `param_index` is the base of - // the curve *on show*, so picking a different one must move it — if it - // did not, dragging a point on the red curve would write to the - // master's. - let graph = EditGraph::default_chain(); - let caps = graph.capabilities(); - let curve_at = caps - .iter() - .position(|c| c.id == curve::ID) - .expect("tone curve present"); - - let mut bases = Vec::new(); - for channel in 0..curve::CHANNELS { - let rows = rows_filtered(&caps, |_| true, channel); - let row = rows - .iter() - .find(|r| r.op_index as usize == curve_at) - .expect("the curve has a row"); - assert_eq!(row.kind, "curve"); - assert_eq!( - row.points.row_count(), - curve::POINTS * 2, - "one curve's points, not all four curves'" - ); - bases.push(row.param_index); - } - - assert_eq!( - bases, - (0..curve::CHANNELS) - .map(|i| (i * curve::POINTS * 2) as i32) - .collect::>() - ); - } - - #[test] - fn a_selection_the_operation_cannot_honour_falls_back_to_its_last_curve() { - // The selection outlives the photograph it was made on, and the next - // image's operation may offer fewer curves. Clamping keeps a plot on - // the grid; the alternative is a curve row that vanishes, which reads - // as the tone curve having disappeared from the panel. - let graph = EditGraph::default_chain(); - let caps = graph.capabilities(); - let rows = rows_filtered(&caps, |_| true, 99); - let row = rows - .iter() - .find(|r| r.kind == "curve") - .expect("the curve still has a row"); - assert_eq!( - row.param_index, - ((curve::CHANNELS - 1) * curve::POINTS * 2) as i32 - ); - } - - #[test] - fn an_operation_whose_points_are_unfaceted_is_one_curve() { - // A curve widget that spans a single unnamed curve — which is what - // this operation was before the channels arrived, and what any other - // node declaring a `tone_curve` widget over ten scalars would be. - // It must draw, and it must offer no choice. - use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand}; - use slint::Model as _; - - static IDS: [ParamId; 4] = [ - ParamId("p0_x"), - ParamId("p0_y"), - ParamId("p1_x"), - ParamId("p1_y"), - ]; - let param = |id: ParamId| ParamCapability { - id, - label: LocalizedKey("param.point"), - kind: ParamKind::Scalar { - min: 0.0, - max: 1.0, - scale: dr_pipeline::Scale::Linear, - unit: Unit::None, - precision: 4, - }, - default: 0.0, - value: 0.0, - facet: None, - }; - let plain = OpCapability { - id: OpId("invented_curve"), - label: LocalizedKey("op.invented_curve"), - active: false, - presentation: Some(Presentation { - widgets: vec![WidgetKind::ToneCurve], - demand: WidgetDemand { - two_dimensional: true, - precise_pointing: true, - }, - params: IDS.to_vec(), - }), - params: IDS.iter().map(|id| param(*id)).collect(), - attributes: vec![dr_pipeline::Attribute::Tone], - }; - - let presentation = plain.presentation.as_ref().expect("declares a widget"); - let runs = curve_runs(&plain, presentation).expect("curve-shaped"); - assert_eq!(runs.len(), 1, "one unnamed curve"); - assert_eq!(runs[0].subject, None); - - let rows = rows_from(&[plain]); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].kind, "curve"); - assert_eq!(rows[0].points.row_count(), IDS.len()); - } - - #[test] - fn curve_samples_start_on_the_diagonal() { - // A fresh curve is the identity, so the drawn line must be the 45° - // diagonal — anything else means the widget opens showing a shape - // the image does not have. - let mut xs = [0.0f32; curve::POINTS]; - let mut ys = [0.0f32; curve::POINTS]; - for i in 0..curve::POINTS { - let t = i as f32 / (curve::POINTS - 1) as f32; - xs[i] = t; - ys[i] = t; - } - for i in 0..=20 { - let x = i as f32 / 20.0; - let y = curve::evaluate(&xs, &ys, x); - assert!((y - x).abs() < 1e-4, "at {x} the identity gave {y}"); - } - } - - #[test] - fn sorting_enforces_a_minimum_gap() { - // Two points dragged onto each other would divide by zero in the - // spline; the drawn curve must survive it exactly as the shader does. - let mut xs = [0.5, 0.5, 0.5, 0.5, 0.5]; - sort_with_gap(&mut xs); - for i in 1..xs.len() { - assert!(xs[i] > xs[i - 1], "not separated: {xs:?}"); - } - } - - #[test] - fn sorting_orders_reversed_points() { - let mut xs = [0.9, 0.7, 0.5, 0.3, 0.1]; - sort_with_gap(&mut xs); - for i in 1..xs.len() { - assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}"); - } - } - - /// A frame black on the left half and white on the right, at `size` - /// square. Both ends of the histogram are occupied and both clipping - /// counters are non-zero, and cropping to one half leaves exactly one of - /// them so. - fn split_frame(size: u32) -> Vec { - let mut rgba = Vec::with_capacity((size * size * 4) as usize); - for _ in 0..size { - for x in 0..size { - let v = if x < size / 2 { 0u8 } else { 255 }; - rgba.extend_from_slice(&[v, v, v, 255]); - } - } - rgba - } - - /// TRACES: FR-DSP-7 - #[test] - fn the_histogram_counts_the_frame_that_is_actually_on_the_canvas() { - // The wiring, end to end and against exact numbers: a 64x64 frame that - // is half black and half white must come back as 2048 pixels at level - // 0, 2048 at 255, and both clipping counters at 2048. - // - // Asserted at the session rather than at the pass because the mistake - // this catches is not arithmetic — `dr_gpu` has its own tests for that - // — it is counting the *wrong texture*. Reading a stale target, or the - // demosaiced source instead of the adjusted output, produces a - // perfectly well-formed histogram of an image the photographer is not - // looking at, which is the one failure mode that cannot be seen. - let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { - log::warn!("no GPU adapter; skipping"); - return; - }; - - let rgba = split_frame(64); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) - .expect("session"); - session.render(64, 64).expect("render"); - - let hist = session.histogram().expect("a rendered session must count"); - assert_eq!(hist.pixels(), 64 * 64); - assert_eq!(hist.red()[0], 2048, "the black half"); - assert_eq!(hist.red()[255], 2048, "the white half"); - assert_eq!(hist.clipped_shadows(), 2048); - assert_eq!(hist.clipped_highlights(), 2048); - } - - /// TRACES: FR-DSP-7 - #[test] - fn the_histogram_follows_the_edit_rather_than_the_file() { - // The property that makes it *live*. A histogram computed once from the - // source would pass the test above and be useless — the whole reason - // FR-DSP-7 exists is to show what an adjustment is doing, so cropping - // away the white half must leave a histogram with no white in it and - // no highlight clipping to report. - let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { - log::warn!("no GPU adapter; skipping"); - return; - }; - - let rgba = split_frame(64); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) - .expect("session"); - - session.set_crop(CropRect { - x: 0.0, - y: 0.0, - width: 0.5, - height: 1.0, - }); - session.render(64, 64).expect("render"); - - let hist = session.histogram().expect("histogram"); - assert_eq!(hist.pixels(), 32 * 64, "the crop halved the frame"); - assert_eq!(hist.red()[0], 32 * 64); - assert_eq!(hist.red()[255], 0, "the white half was cropped away"); - assert_eq!(hist.clipped_highlights(), 0); - assert_eq!(hist.clipped_shadows(), 32 * 64); - } - - /// A flat Bayer frame whose every photosite normalises to `level`. - /// - /// Black at zero and a power-of-two white level, so the normalisation is - /// exact and the value the raw histogram sees is the one this asked for - /// rather than one rounded by two divisions. - fn flat_raw(size: u32, level: f32) -> RawImage { - const WHITE: u16 = 16384; - let sample = (level * f32::from(WHITE)).round() as u16; - RawImage { - width: size, - height: size, - data: vec![sample; (size * size) as usize], - cfa_pattern: dr_decode::CfaPattern::Rggb, - black_level: [0; 4], - white_level: WHITE, - wb_coeffs: [1.0, 1.0, 1.0, 1.0], - color_matrix: None, - base_curve: dr_decode::BaseCurve::IDENTITY, - samples_per_pixel: 1, - profile: None, - make: String::new(), - model: String::new(), - crop: dr_decode::CropRect { - x: 0, - y: 0, - width: size, - height: size, - }, - } - } - - /// TRACES: FR-CULL-3 - #[test] - fn the_raw_histogram_describes_the_file_and_not_the_view() { - // **The property that makes it a second instrument rather than a - // second rendering of the first**, and the one every other test here - // would pass without. The display histogram beside it deliberately - // follows the edit and the visible region — that is what FR-DSP-7 - // asks of it. This must do neither: cropping away half the photograph - // changes what is on the canvas and changes nothing about what the - // sensor recorded, and a culler asking how much latitude an exposure - // has is asking about the capture. - // - // Reading the adjusted output by mistake would pass a plausible-looking - // plot back — which is exactly why this asserts the *denominator* and - // the bin, not merely that something was counted. - let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { - log::warn!("no GPU adapter; skipping"); - return; - }; - - // 0.234253 is the centre of the bin 33 sixteenths below saturation — - // mid-bin on purpose, so the assertion is about the reduction rather - // than about how this machine's `log2` rounds an exact tie. - let raw = flat_raw(16, 3838.0 / 16384.0); - let mut session = - DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); - - let before = session.raw_histogram().expect("a raw session must count"); - assert_eq!(before.pixels(), 16 * 16); - assert_eq!( - before.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1 - 33], - 16 * 16, - "a flat frame two stops down did not land in one bin" - ); - assert_eq!(before.saturated(), 0, "nothing here is at the white level"); - assert_eq!(before.at_black(), 0); - - session.set_crop(CropRect { - x: 0.0, - y: 0.0, - width: 0.5, - height: 1.0, - }); - session.render(16, 16).expect("render"); - - // The display histogram followed the crop, as it is supposed to. - // Asserted as an inequality rather than an exact figure: how a half - // crop of a 16px frame rounds to a viewport is `AdjustPass`'s - // business and has its own tests, and pinning it here would make this - // test fail for a reason it is not about. - let shown = session.histogram().expect("histogram"); - assert!( - shown.pixels() < 16 * 16, - "the crop did not reach the display histogram, so this proves nothing" - ); - - // The raw one did not. - let after = session.raw_histogram().expect("raw histogram"); - assert_eq!( - after, before, - "the raw reading followed the crop, so it is measuring the render" - ); - } - - /// TRACES: FR-CULL-3 - #[test] - fn a_blown_frame_reads_as_clipped_in_the_raw_domain() { - // The other end, and the reason the requirement exists. Every - // photosite at the white level is a photograph with no highlight - // headroom left in the file — no edit recovers it — and the instrument - // has to say so in the same terms whatever the develop chain currently - // makes of it. - let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { - log::warn!("no GPU adapter; skipping"); - return; - }; - - let raw = flat_raw(16, 1.0); - let mut session = - DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); - - let hist = session.raw_histogram().expect("raw histogram"); - assert_eq!(hist.pixels(), 16 * 16); - assert_eq!(hist.saturated(), 16 * 16); - assert_eq!(hist.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1], 16 * 16); - } - - /// TRACES: FR-CULL-3 - #[test] - fn a_file_with_no_sensor_data_has_no_raw_reading_rather_than_a_wrong_one() { - // The JPEG path. Its source texture is gamma-encoded and carries no - // white level, so there is no scale to measure headroom against — and - // counting it anyway would produce a confident plot of a quantity that - // does not exist, which is the failure mode an instrument must not - // have. The panel says there is nothing to say. - let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { - log::warn!("no GPU adapter; skipping"); - return; - }; - - let rgba = split_frame(16); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) - .expect("session"); - - assert!(!session.has_sensor_data()); - assert!(session.raw_histogram().is_none()); - } - - #[test] - fn routing_indices_map_back_to_the_right_parameter() { - // A wrong index would silently move the wrong slider's value, which - // is exactly the kind of bug that looks like a rendering fault. - let graph = EditGraph::default_chain(); - let caps = graph.capabilities(); - for (oi, op) in caps.iter().enumerate() { - for (pi, p) in op.params.iter().enumerate() { - assert_eq!(caps[oi].params[pi].id, p.id); - assert_eq!(caps[oi].id, op.id); - } - } - } - - // ---------------------------------------------------------------------- - // Group tabs (ARCH §4.3a, FR-DEV-3a) - // ---------------------------------------------------------------------- - - fn tabbed_session(ctx: &GpuContext) -> DevelopSession { - let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); - DevelopSession::open_rgb(ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) - .expect("session") - } - - #[test] - fn the_tabs_come_from_the_chain_not_from_a_list() { - let Some(ctx) = headless() else { return }; - let session = tabbed_session(&ctx); - - let names: Vec = session.tabs().into_iter().map(|(_, n)| n).collect(); - assert!(names.contains(&"Light".to_string()), "got {names:?}"); - assert!(names.contains(&"Colour".to_string()), "got {names:?}"); - // Nothing offers a group with no rows behind it. - for (attribute, name) in session.tabs() { - let mut probe = tabbed_session(&ctx); - let index = probe - .tabs() - .iter() - .position(|(a, _)| *a == attribute) - .expect("just listed"); - probe.set_active_tab(index as i32); - assert!(!probe.rows().is_empty(), "tab {name} opens onto nothing"); - } - } - - #[test] - fn choosing_a_tab_narrows_the_panel() { - let Some(ctx) = headless() else { return }; - let mut session = tabbed_session(&ctx); - - let all = session.rows().len(); - session.set_active_tab(0); - let narrowed = session.rows().len(); - - assert!(narrowed > 0, "a tab must show something"); - assert!( - narrowed < all, - "and less than everything: {narrowed} of {all}" - ); - } - - /// The trap: `op_index` counts over *every* capability, so a row that - /// survived a filter must still route to the operation it came from. If it - /// renumbered, a slider would drive a different operation once a tab was - /// chosen. - #[test] - fn a_filtered_row_still_drives_its_own_operation() { - let Some(ctx) = headless() else { return }; - let mut session = tabbed_session(&ctx); - - // Find a colour row while unfiltered, and remember where it points. - let colour = session - .tabs() - .iter() - .position(|(_, n)| n == "Colour") - .expect("the chain has colour operations"); - session.set_active_tab(colour as i32); - - let row = session.rows().into_iter().next().expect("a row"); - let (op, param) = (row.op_index, row.param_index); - - session.set_param(op, param, 0.5); - let after = session - .rows() - .into_iter() - .find(|r| r.op_index == op && r.param_index == param) - .expect("the row survived"); - - assert!( - (after.value - 0.5).abs() < 1e-5, - "the value landed on the row that asked for it, not another" - ); - } - - #[test] - fn all_is_reachable_again() { - let Some(ctx) = headless() else { return }; - let mut session = tabbed_session(&ctx); - let all = session.rows().len(); - - session.set_active_tab(0); - assert!(session.rows().len() < all); - - session.set_active_tab(-1); - assert_eq!(session.rows().len(), all, "-1 means everything"); - assert_eq!(session.active_tab(), -1); - } - - /// An out-of-range index is navigation nonsense, not an edit; it must not - /// leave the panel showing nothing. - #[test] - fn a_nonsense_tab_falls_back_to_everything() { - let Some(ctx) = headless() else { return }; - let mut session = tabbed_session(&ctx); - let all = session.rows().len(); - - session.set_active_tab(99); - assert_eq!(session.rows().len(), all); - } - - // ---------------------------------------------------------------------- - // Per-display colour (FR-DSP-8) - // ---------------------------------------------------------------------- - - /// TRACES: FR-DSP-8 | FR-DSP-6 - /// The canvas is encoded for the display, not always for sRGB. - /// - /// This is the assertion the requirement is actually about. Before it, - /// `render` composed `ColourSpace::Srgb` unconditionally, and a second - /// monitor with a different profile got sRGB pixels *labelled* as its own - /// space by the compositor — the silent wrongness FR-DSP-8 calls a - /// correctness defect. If someone re-hardcodes the space, these pixels - /// stop differing and this fails. - /// - /// A saturated red is the probe deliberately: it sits near the edge of - /// sRGB's gamut, so re-encoding it into a wider one moves it a long way, - /// where a mid grey would move by almost nothing in any of the four and - /// the test would pass on a broken build. - #[test] - fn the_canvas_is_encoded_for_the_display_showing_it() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..64 * 64).flat_map(|_| [230u8, 20, 20, 255]).collect(); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) - .expect("session"); - - assert_eq!( - session.display_space(), - dr_types::ColourSpace::Srgb, - "a session starts on the fallback, so nothing changes for a \ - desktop whose display server will not say otherwise" - ); - let on_srgb = read_back(&ctx, &session.render(64, 64).expect("render")); - - assert!(session.set_display_space(dr_types::ColourSpace::AdobeRgb)); - let on_wide = read_back(&ctx, &session.render(64, 64).expect("render")); - - assert_ne!( - on_srgb, on_wide, - "the same edit rendered for two displays produced the same pixels" - ); - - // And back again, because a photographer dragging a window between - // two monitors expects the first one to look as it did rather than to - // accumulate a transform. - assert!(session.set_display_space(dr_types::ColourSpace::Srgb)); - let returned = read_back(&ctx, &session.render(64, 64).expect("render")); - assert_eq!(on_srgb, returned); - } - - /// TRACES: FR-DSP-8 - /// A move that changes nothing reports nothing, so nothing is redrawn. - /// - /// The window's position is polled twice a second and two displays often - /// share a profile. A setter that reported a change every time it was - /// called would turn that poll into a redraw loop. - #[test] - fn setting_the_same_display_space_twice_is_not_a_change() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) - .expect("session"); - - assert!(session.set_display_space(dr_types::ColourSpace::DisplayP3)); - assert!(!session.set_display_space(dr_types::ColourSpace::DisplayP3)); - assert_eq!(session.display_space(), dr_types::ColourSpace::DisplayP3); - } - - /// TRACES: FR-DSP-8 | FR-EXP-2 - /// The display's space is the *canvas's*, and reaches nothing else. - /// - /// A thumbnail goes into a shard that syncs between devices and an export - /// claims the space the export dialogue asked for. Letting the monitor in - /// front of the photographer decide either would write a file whose - /// profile describes the desk it was made at. - #[test] - fn a_wide_gamut_monitor_does_not_reach_the_thumbnail_or_the_export() { - let Some(ctx) = headless() else { return }; - let rgba: Vec = (0..32 * 32).flat_map(|_| [230u8, 20, 20, 255]).collect(); - let mut session = - DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL) - .expect("session"); - - let thumb_before = session.render_thumbnail(16).expect("thumbnail"); - let export_before = session - .render_for_export(dr_types::ColourSpace::Srgb) - .expect("export") - .rgba; - - session.set_display_space(dr_types::ColourSpace::ProPhoto); - - assert_eq!( - session.render_thumbnail(16).expect("thumbnail"), - thumb_before, - "the grid's thumbnail followed the monitor" - ); - assert_eq!( - session - .render_for_export(dr_types::ColourSpace::Srgb) - .expect("export") - .rgba, - export_before, - "an sRGB export followed the monitor" - ); - } -} diff --git a/ui/dr-ui/src/develop/curves.rs b/ui/dr-ui/src/develop/curves.rs new file mode 100644 index 0000000..d20f198 --- /dev/null +++ b/ui/dr-ui/src/develop/curves.rs @@ -0,0 +1,523 @@ +//! The tone-curve widget: one run of points per subject, and the session's +//! curve-channel selection that picks which one is being dragged. +use dr_pipeline::ops::curve; +use dr_pipeline::{OpCapability, Presentation, WidgetKind}; + +use crate::labels; +use crate::ParamRow; + +#[cfg(test)] +use super::rows::rows_filtered; +#[cfg(test)] +use super::rows::rows_from; +use super::rows::{no_choices, supported}; +use super::session::DevelopSession; +#[cfg(test)] +use dr_pipeline::{EditGraph, OpId, ParamId, ParamKind, Unit}; + +/// One run of a curve widget's parameters: the points of a single curve. +/// +/// A widget may span several curves — the tone curve is one plot over a master +/// curve and three colour channels — and it says so the way the colour mixer +/// says it has twelve bands: by faceting each parameter with the *subject* it +/// acts on. Consecutive parameters sharing a subject are one curve. +struct CurveRun { + /// The subject's localisation key, or `None` where the widget's parameters + /// carry no facet at all and are therefore a single unnamed curve. + subject: Option<&'static str>, + /// Where this run's points begin in the operation's parameter list. What + /// a drag routes back through, so it must be a position in `op.params` + /// and not in the presentation's list. + base: usize, + /// How many coordinates it holds. + len: usize, +} + +/// TRACES: FR-DEV-3a +/// The curves a curve widget spans, in the order the operation declares them. +/// +/// **This is the whole of the panel's knowledge of colour channels: none.** It +/// groups by whatever subject the parameters carry, so an operation offering a +/// master curve and three channels gets a four-way selector, one offering a +/// single unfaceted curve gets no selector at all, and one that grows a fifth +/// curve tomorrow needs no change here. +/// +/// Returns `None` where the parameters do not look like point coordinates — +/// an odd count, a run that is not contiguous in the capability list — in +/// which case the caller falls back to sliders rather than drawing a widget +/// over a layout it has guessed at. +fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option> { + // Points are x/y pairs, so an odd count means the operation and this code + // disagree about the layout. + if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) { + log::warn!("{}: curve widget needs an even parameter count", op.id); + return None; + } + + let mut runs: Vec = Vec::new(); + for id in &presentation.params { + // The widget addresses points by offset from the first of its run, so + // a run has to be contiguous in the capability list. + let at = op.params.iter().position(|p| p.id == *id)?; + let subject = op.params[at].facet.as_ref().map(|f| f.subject.0); + + match runs.last_mut() { + Some(run) if run.subject == subject && run.base + run.len == at => run.len += 1, + _ => runs.push(CurveRun { + subject, + base: at, + len: 1, + }), + } + } + + if runs.iter().any(|r| !r.len.is_multiple_of(2)) { + log::warn!("{}: a curve's points are not contiguous", op.id); + return None; + } + Some(runs) +} + +/// One row standing for a whole curve. +/// +/// `channel` picks which of the widget's curves is plotted; it is clamped +/// rather than validated, because the selection is interface state that +/// outlives a change of photograph and the new image's operation may have +/// fewer curves than the old one's. +/// +/// Returns `None` if the operation's parameters do not look like point +/// coordinates, in which case the caller falls back to sliders rather than +/// rendering a broken widget. +pub(super) fn curve_row( + op_index: usize, + group_head: usize, + op: &OpCapability, + presentation: &Presentation, + channel: usize, +) -> Option { + let runs = curve_runs(op, presentation)?; + let run = runs.get(channel.min(runs.len().saturating_sub(1)))?; + + let points: Vec = op.params[run.base..run.base + run.len] + .iter() + .map(|p| p.value) + .collect(); + + Some(ParamRow { + op_index: op_index as i32, + // The first point parameter *of the curve on show*; the widget offsets + // from here, so switching curve is what re-points the drag. + param_index: run.base as i32, + op_label: labels::resolve(op.label.0).into(), + param_label: String::new().into(), + // A widget spanning a whole operation is not a row in anyone's + // grid, so it heads no run and carries no swatch. + facet_label: String::new().into(), + starts_facet: false, + swatch_hue: -1.0, + group_head: group_head as i32, + // One widget standing for every parameter of the operation, so + // the group it heads is itself and nothing else. + group_len: 1, + group_modified: op.params.iter().any(|p| p.value != p.default), + // A curve is drawn, not sampled. Its own affordance is the plot. + group_samples: false, + kind: "curve".into(), + value: 0.0, + default_value: 0.0, + minimum: 0.0, + maximum: 1.0, + precision: 4, + unit: String::new().into(), + points: slint::ModelRc::new(slint::VecModel::from(points)), + // A curve is not a choice between named alternatives. The curves it + // can switch between are named on the panel rather than on the row — + // see `DevelopSession::curve_channels` for why they cannot ride here. + choices: no_choices(), + }) +} + +/// Sort ascending and force a minimum separation. +/// +/// Mirrors what the curve operation does before handing points to the +/// shader. Duplicated rather than shared because the operation keeps it +/// private, and the consequence of drift is only a drawn line that lags the +/// rendered one by a pixel — not a wrong image. +fn sort_with_gap(xs: &mut [f32]) { + const MIN_GAP: f32 = 0.001; + for i in 1..xs.len() { + let mut j = i; + while j > 0 && xs[j - 1] > xs[j] { + xs.swap(j - 1, j); + j -= 1; + } + } + for i in 1..xs.len() { + if xs[i] - xs[i - 1] < MIN_GAP { + xs[i] = xs[i - 1] + MIN_GAP; + } + } +} + +impl DevelopSession { + /// TRACES: FR-DEV-3 + /// The names of the curves the widget can switch between. + /// + /// Empty where there is only one, which is also the answer for a frontend + /// with no curve at all: a selector over a single choice is a row of + /// nothing. + /// + /// **Derived from the facets, so nothing here names a colour channel.** + /// The operation says its forty points are one control applied to four + /// subjects and publishes a localisation key for each; this resolves the + /// keys and hands over four words. An operation that grew a fifth curve + /// would appear here on its own. + /// + /// A panel property rather than a field on the curve's `ParamRow`, and the + /// reason is Slint's: a row's models are compared by identity, so a fresh + /// list of names built on every parameter event would make the row look + /// changed every time, and rewriting a row rebuilds the repeater item + /// underneath it — destroying the `TouchArea` holding the drag in + /// progress. The same hazard `rows`'s in-place point update exists to + /// avoid. Nothing in this list is a drag target, so up here it is safe to + /// replace wholesale, exactly as [`Self::curve_samples`] is. + pub fn curve_channels(&self) -> Vec { + for op in &self.scoped_capabilities() { + let Some(presentation) = &op.presentation else { + continue; + }; + if presentation.choose(supported) != Some(WidgetKind::ToneCurve) { + continue; + } + let Some(runs) = curve_runs(op, presentation) else { + continue; + }; + if runs.len() < 2 { + continue; + } + return runs + .iter() + .map(|r| r.subject.map(labels::resolve).unwrap_or_default()) + .collect(); + } + Vec::new() + } + + /// Which curve the widget is plotting, as an index into + /// [`Self::curve_channels`]. + pub fn curve_channel(&self) -> i32 { + self.curve_channel as i32 + } + + /// Plot a different one of the operation's curves. + /// + /// Out-of-range indices are ignored rather than clamped: the only thing + /// that can send one is a stale interface event, and quietly moving the + /// selection somewhere the user did not point is worse than doing nothing. + pub fn set_curve_channel(&mut self, index: i32) { + let Ok(index) = usize::try_from(index) else { + return; + }; + if index < self.curve_channels().len() { + self.curve_channel = index; + } + } + + /// The plotted curve's shape, sampled for drawing. + /// + /// Evaluated with `dr_pipeline`'s own spline, so the line the user drags + /// is the line the shader applies. The alternative — reading the curve + /// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a + /// polyline. + /// + /// The line drawn is the *selected* curve's own shape, not the composition + /// of it with the master. Two curves overlaid on one grid is a plot of two + /// things, and the one being dragged has to be the one whose points are + /// under the pointer. + pub fn curve_samples(&self) -> Vec { + const SAMPLES: usize = 96; + + // The selection is an index over the subjects the panel found, which + // for this operation is its channel order. Clamped rather than + // trusted: a selection made on one photograph outlives the change to + // the next. + let channel = curve::Channel::ALL[self.curve_channel.min(curve::CHANNELS - 1)]; + + let mut xs = [0.0f32; curve::POINTS]; + let mut ys = [0.0f32; curve::POINTS]; + let mut found = false; + + for cap in self.graph.capabilities() { + if cap.id != curve::ID { + continue; + } + found = true; + // By id rather than by position, so which curve is plotted is + // decided by naming it and not by arithmetic over the parameter + // list. + let value = |id| { + cap.params + .iter() + .find(|p| p.id == id) + .map_or(0.0, |p| p.value) + }; + for i in 0..curve::POINTS { + xs[i] = value(curve::coordinate(channel, i, curve::Axis::X)); + ys[i] = value(curve::coordinate(channel, i, curve::Axis::Y)); + } + } + if !found { + return Vec::new(); + } + + // Sorted the same way the operation sorts before handing points to + // the shader, or a dragged-past point would draw differently from + // how it renders. + sort_with_gap(&mut xs); + + (0..SAMPLES) + .map(|i| { + let x = i as f32 / (SAMPLES - 1) as f32; + curve::evaluate(&xs, &ys, x).clamp(0.0, 1.0) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_curve_collapses_to_a_single_row() { + // Every point parameter — all four curves' worth — must appear as one + // curve control, not as forty sliders. Otherwise the widget and the + // sliders both render and the panel shows the same values twice. + let graph = EditGraph::default_chain(); + let curve_cap = graph + .capabilities() + .into_iter() + .find(|c| c.id == curve::ID) + .expect("the chain includes a tone curve"); + + assert_eq!( + curve_cap.params.len(), + curve::CHANNELS * curve::POINTS * 2, + "a master curve and one per colour channel" + ); + let presentation = curve_cap + .presentation + .as_ref() + .expect("the curve declares a widget"); + // Asked the way the panel asks it: the first preference this frontend + // implements, not a fixed single kind. + assert_eq!(presentation.choose(supported), Some(WidgetKind::ToneCurve)); + // Every parameter is owned by the widget, so none is left over to be + // rendered as a stray slider. + assert_eq!(presentation.params.len(), curve_cap.params.len()); + } + + #[test] + fn curve_point_parameters_are_contiguous() { + // The widget addresses points by offset from the first. Were they + // interleaved with anything else, dragging a point would write to + // the wrong parameter. + let graph = EditGraph::default_chain(); + let cap = graph + .capabilities() + .into_iter() + .find(|c| c.id == curve::ID) + .expect("tone curve present"); + let presentation = cap.presentation.as_ref().expect("declares a widget"); + + let base = cap + .params + .iter() + .position(|p| p.id == presentation.params[0]) + .expect("first point is a parameter"); + for (i, id) in presentation.params.iter().enumerate() { + assert_eq!( + cap.params[base + i].id, + *id, + "point parameter {i} is out of order" + ); + } + } + + /// The panel's whole knowledge of colour channels, asserted to be none. + /// + /// It groups the widget's parameters by the subject the *operation* put on + /// them and finds four curves; nothing below says "red", and an operation + /// that grew a fifth curve would arrive here on its own. + #[test] + fn a_curve_widget_offers_one_run_per_subject() { + let graph = EditGraph::default_chain(); + let cap = graph + .capabilities() + .into_iter() + .find(|c| c.id == curve::ID) + .expect("tone curve present"); + let presentation = cap.presentation.as_ref().expect("declares a widget"); + + let runs = curve_runs(&cap, presentation).expect("a curve-shaped operation"); + assert_eq!(runs.len(), curve::CHANNELS); + for (i, run) in runs.iter().enumerate() { + assert_eq!(run.len, curve::POINTS * 2, "run {i} is not five points"); + assert_eq!(run.base, i * curve::POINTS * 2); + assert!(run.subject.is_some(), "run {i} is unnamed"); + } + } + + #[test] + fn switching_curve_repoints_the_row() { + use slint::Model as _; + + // What a drag routes through. The row's `param_index` is the base of + // the curve *on show*, so picking a different one must move it — if it + // did not, dragging a point on the red curve would write to the + // master's. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + let curve_at = caps + .iter() + .position(|c| c.id == curve::ID) + .expect("tone curve present"); + + let mut bases = Vec::new(); + for channel in 0..curve::CHANNELS { + let rows = rows_filtered(&caps, |_| true, channel); + let row = rows + .iter() + .find(|r| r.op_index as usize == curve_at) + .expect("the curve has a row"); + assert_eq!(row.kind, "curve"); + assert_eq!( + row.points.row_count(), + curve::POINTS * 2, + "one curve's points, not all four curves'" + ); + bases.push(row.param_index); + } + + assert_eq!( + bases, + (0..curve::CHANNELS) + .map(|i| (i * curve::POINTS * 2) as i32) + .collect::>() + ); + } + + #[test] + fn a_selection_the_operation_cannot_honour_falls_back_to_its_last_curve() { + // The selection outlives the photograph it was made on, and the next + // image's operation may offer fewer curves. Clamping keeps a plot on + // the grid; the alternative is a curve row that vanishes, which reads + // as the tone curve having disappeared from the panel. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + let rows = rows_filtered(&caps, |_| true, 99); + let row = rows + .iter() + .find(|r| r.kind == "curve") + .expect("the curve still has a row"); + assert_eq!( + row.param_index, + ((curve::CHANNELS - 1) * curve::POINTS * 2) as i32 + ); + } + + #[test] + fn an_operation_whose_points_are_unfaceted_is_one_curve() { + // A curve widget that spans a single unnamed curve — which is what + // this operation was before the channels arrived, and what any other + // node declaring a `tone_curve` widget over ten scalars would be. + // It must draw, and it must offer no choice. + use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand}; + use slint::Model as _; + + static IDS: [ParamId; 4] = [ + ParamId("p0_x"), + ParamId("p0_y"), + ParamId("p1_x"), + ParamId("p1_y"), + ]; + let param = |id: ParamId| ParamCapability { + id, + label: LocalizedKey("param.point"), + kind: ParamKind::Scalar { + min: 0.0, + max: 1.0, + scale: dr_pipeline::Scale::Linear, + unit: Unit::None, + precision: 4, + }, + default: 0.0, + value: 0.0, + facet: None, + }; + let plain = OpCapability { + id: OpId("invented_curve"), + label: LocalizedKey("op.invented_curve"), + active: false, + presentation: Some(Presentation { + widgets: vec![WidgetKind::ToneCurve], + demand: WidgetDemand { + two_dimensional: true, + precise_pointing: true, + }, + params: IDS.to_vec(), + }), + params: IDS.iter().map(|id| param(*id)).collect(), + attributes: vec![dr_pipeline::Attribute::Tone], + }; + + let presentation = plain.presentation.as_ref().expect("declares a widget"); + let runs = curve_runs(&plain, presentation).expect("curve-shaped"); + assert_eq!(runs.len(), 1, "one unnamed curve"); + assert_eq!(runs[0].subject, None); + + let rows = rows_from(&[plain]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].kind, "curve"); + assert_eq!(rows[0].points.row_count(), IDS.len()); + } + + #[test] + fn curve_samples_start_on_the_diagonal() { + // A fresh curve is the identity, so the drawn line must be the 45° + // diagonal — anything else means the widget opens showing a shape + // the image does not have. + let mut xs = [0.0f32; curve::POINTS]; + let mut ys = [0.0f32; curve::POINTS]; + for i in 0..curve::POINTS { + let t = i as f32 / (curve::POINTS - 1) as f32; + xs[i] = t; + ys[i] = t; + } + for i in 0..=20 { + let x = i as f32 / 20.0; + let y = curve::evaluate(&xs, &ys, x); + assert!((y - x).abs() < 1e-4, "at {x} the identity gave {y}"); + } + } + + #[test] + fn sorting_enforces_a_minimum_gap() { + // Two points dragged onto each other would divide by zero in the + // spline; the drawn curve must survive it exactly as the shader does. + let mut xs = [0.5, 0.5, 0.5, 0.5, 0.5]; + sort_with_gap(&mut xs); + for i in 1..xs.len() { + assert!(xs[i] > xs[i - 1], "not separated: {xs:?}"); + } + } + + #[test] + fn sorting_orders_reversed_points() { + let mut xs = [0.9, 0.7, 0.5, 0.3, 0.1]; + sort_with_gap(&mut xs); + for i in 1..xs.len() { + assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}"); + } + } +} diff --git a/ui/dr-ui/src/develop/framing.rs b/ui/dr-ui/src/develop/framing.rs new file mode 100644 index 0000000..e11acfe --- /dev/null +++ b/ui/dr-ui/src/develop/framing.rs @@ -0,0 +1,952 @@ +//! The crop, its locked aspect ratio, rotation and flips, and the zoom/pan +//! and pixel-inspection state a viewport keeps on top of the framed image. +use dr_pipeline::{CropRect, Edit}; + +use crate::labels; + +use super::render::fit; +use super::session::DevelopSession; + +/// TRACES: FR-DEV-3 +/// A shape the crop rectangle is held to while it is dragged. +/// +/// A photographer cropping for a print, a phone wallpaper or a 16:9 frame is +/// not choosing four edges — they are choosing one edge and a known shape, and +/// a free crop makes them do the arithmetic by eye on every drag. This is the +/// lock that removes it. +/// +/// **The ratio is of output pixels, not of the rect's own numbers.** The rect +/// is stored in fractions of a frame that is not square, so `CropRect` needs +/// the frame's size to hold a shape; see [`CropRect::with_aspect`], which is +/// where that conversion is done and explained. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CropAspect { + /// Any shape. The handles move independently, as they always have. + #[default] + Free, + /// Whatever the frame already is, so a crop trims without reshaping. + /// + /// Not the same as `Fixed(3, 2)` even on a 3:2 camera: it follows the + /// frame, so it stays right on the next photograph from another body and + /// after a quarter turn. + Original, + /// A named ratio of `w:h`, before the portrait switch is applied. + Fixed(u32, u32), +} + +impl CropAspect { + /// The ratios the panel offers, in the order it draws them. + /// + /// Short on purpose. These sit as chips in a column narrow enough for a + /// tablet, and every ratio a photographer reaches for repeatedly is here: + /// the frame's own shape, the square, the two classic camera ratios, the + /// large-format one that most print papers follow, and video's. + pub const CHOICES: [Self; 6] = [ + Self::Free, + Self::Original, + Self::Fixed(1, 1), + Self::Fixed(3, 2), + Self::Fixed(4, 3), + Self::Fixed(16, 9), + ]; + + /// The chip's text. + pub fn label(self) -> String { + match self { + Self::Free => "Free".to_string(), + Self::Original => "Original".to_string(), + Self::Fixed(w, h) => format!("{w}:{h}"), + } + } + + /// Whether this choice has a portrait form at all. + /// + /// A square does not, and neither does `Free`. The switch is disabled + /// rather than hidden for those, so the row does not change shape as the + /// chips are tried. + pub fn has_orientation(self) -> bool { + !matches!(self, Self::Free | Self::Fixed(1, 1)) + } + + /// TRACES: FR-DEV-3 + /// Whether a quarter turn of the frame has to flip the orientation switch + /// to leave this ratio describing the same shape. + /// + /// A quarter turn carries the crop with it — that is what makes turning a + /// photograph keep its composition — so a rect locked to 16:9 comes out of + /// the turn at 9:16, and the switch has to agree or the next drag would + /// snap the crop back and undo the turn's effect on it. + /// + /// `Original` is deliberately *not* included, and getting that wrong flips + /// it twice. It is resolved against the framed size every time it is + /// asked for, and a quarter turn swaps that frame's axes — so it has + /// already turned by the time anything asks. + pub fn turns_with_the_frame(self) -> bool { + matches!(self, Self::Fixed(w, h) if w != h) + } + + /// Width over height in output pixels, or `None` where nothing is locked. + /// + /// `frame` is the framed size the crop is measured against — the turned + /// frame, not the sensor — which is what makes `Original` follow a quarter + /// turn instead of becoming a portrait crop on a landscape photograph. + pub fn ratio(self, frame: (u32, u32), portrait: bool) -> Option { + let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32); + let landscape = match self { + Self::Free => return None, + Self::Original => fw / fh, + Self::Fixed(w, h) => w.max(1) as f32 / h.max(1) as f32, + }; + Some(if portrait && self.has_orientation() { + 1.0 / landscape + } else { + landscape + }) + } +} + +/// Re-express a crop rect after the frame it is measured against turns. +/// +/// The crop lives in fractions of the *framed* image — the one the quarter +/// turns have already produced — so turning the frame another quarter leaves +/// the rect describing the wrong region unless it turns with it. Without this, +/// rotating a portrait crop on a landscape photograph slides the selection +/// onto a different part of the picture, which reads as the rotation having +/// moved the image rather than the frame. +/// +/// One clockwise quarter takes `(x, y)` to `(1 - y - h, x)` and exchanges the +/// extents; anticlockwise is the same map run the other way. Applied +/// `turns.rem_euclid(4)` times so the caller's wrapping and this agree. +fn rotate_crop(rect: CropRect, turns: i32) -> CropRect { + let mut r = rect; + for _ in 0..turns.rem_euclid(4) { + r = CropRect { + x: 1.0 - r.y - r.height, + y: r.x, + width: r.height, + height: r.width, + }; + } + r.normalised() +} + +impl DevelopSession { + /// The sensor's own dimensions, before framing. + /// + /// What a crop overlay needs: its handles are placed against the full + /// frame, since that is what the user is selecting *from*. + pub fn sensor_size(&self) -> (u32, u32) { + self.demosaiced.size() + } + + /// Whether one source pixel now covers more than one screen pixel. + /// + /// The question the interface asks to decide how the canvas is *filtered*, + /// not how it is rendered. Below 1:1 there are more source pixels than + /// screen pixels and smoothing is what stops the image aliasing; past it + /// there is no more detail to show, and smoothing only invents values + /// between real ones — at which point a photographer inspecting focus or + /// noise wants to see the pixels, not a blur of them. + /// + /// Measured against the visible region rather than the zoom factor alone, + /// because the two differ: a 24 MP file in a 1200px viewport is still + /// showing five sensor pixels per screen pixel at 4×, while a small JPEG is + /// already magnified at 1×. + pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool { + let (sw, sh) = self.demosaiced.size(); + let (fw, fh) = self.graph.output_size(sw, sh); + let (rw, rh) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1)); + + // How many source pixels lie behind the render target: the framed + // image narrowed to the region the view selects. The target keeps its + // size while that region shrinks, which is what raises the ratio. + let view = self.graph.framing().view(); + let behind_w = f64::from(fw) * f64::from(view.width.max(f32::EPSILON)); + let behind_h = f64::from(fh) * f64::from(view.height.max(f32::EPSILON)); + + // Strictly greater, with a margin: at exactly 1:1 either filter gives + // the same answer, and flipping mode on a rounding error would make the + // canvas visibly change character mid-scroll. + f64::from(rw) > behind_w * 1.001 && f64::from(rh) > behind_h * 1.001 + } + + /// Set the crop rectangle, in fractions of the source. + pub fn set_crop(&mut self, rect: CropRect) { + self.graph.set_crop(rect); + // Keyed on the operation, not on a parameter: one drag of one handle + // moves the origin and the extent together. + self.history + .record(&self.graph, Edit::Op(dr_pipeline::framing::ID)); + } + + /// TRACES: FR-DEV-3 + /// Set the crop rectangle, held to `aspect` about `anchor`. + /// + /// The frame size the ratio needs is this session's own, so the caller + /// passes a shape rather than a rectangle and never has to know what a + /// quarter turn did to the frame's dimensions. + /// + /// `anchor` is the point of the rect that must not move, in the rect's own + /// `0..1` coordinates — the corner *opposite* the handle being dragged, so + /// that shaping the rect onto the ratio pushes the held corner and leaves + /// the far one where the user put it. + pub fn set_crop_locked( + &mut self, + rect: CropRect, + aspect: CropAspect, + portrait: bool, + anchor: (f32, f32), + ) { + let frame = self.framed_size(); + let rect = match aspect.ratio(frame, portrait) { + Some(r) => rect.with_aspect(frame.0, frame.1, r, anchor), + None => rect, + }; + self.set_crop(rect); + } + + pub fn crop(&self) -> CropRect { + self.graph.crop() + } + + /// TRACES: FR-DEV-3 + /// The whole frame the crop is measured against, in output pixels. + /// + /// The *framed* size, not the sensor's: quarter turns swap the axes, and a + /// ratio resolved against the sensor would come out on its side the moment + /// a portrait photograph was turned upright. The crop is excluded because + /// this is the shape being selected *from*. + pub fn framed_size(&self) -> (u32, u32) { + let (sw, sh) = self.demosaiced.size(); + self.graph.framing().output_size_uncropped(sw, sh) + } + + /// Rotate by quarter turns, wrapping. The rotate-left/right buttons. + /// + /// The crop travels with the frame rather than staying where it was on + /// screen. A crop is a decision about *this part of the photograph*, and + /// leaving the rect in place while the image turns under it would move the + /// selection onto a different part of the picture — so the rect is turned + /// by the same quarter and the composition survives the rotation. + pub fn rotate_quarters(&mut self, turns: i32) { + let crop = self.graph.crop(); + if !crop.is_full() { + self.graph.set_crop(rotate_crop(crop, turns)); + } + self.graph.rotate_quarters(turns); + self.history + .record(&self.graph, Edit::Action(labels::step::ROTATE)); + } + + /// Straightening, in degrees. Positive turns the image clockwise. + pub fn angle(&self) -> f32 { + self.graph.framing().angle() + } + + /// Quarter turns clockwise, 0..=3 — for the panel's readout. + pub fn quarter_turns(&self) -> u8 { + self.graph.framing().quarter_turns() + } + + pub fn flips(&self) -> (bool, bool) { + self.graph.framing().flips() + } + + /// Mirror horizontally, about the frame's vertical centre line. + pub fn toggle_flip_h(&mut self) { + let (h, _) = self.graph.framing().flips(); + self.graph.set_param( + dr_pipeline::framing::ID, + dr_pipeline::framing::FLIP_H, + f32::from(u8::from(!h)), + ); + self.history + .record(&self.graph, Edit::Action(labels::step::FLIP_H)); + } + + pub fn toggle_flip_v(&mut self) { + let (_, v) = self.graph.framing().flips(); + self.graph.set_param( + dr_pipeline::framing::ID, + dr_pipeline::framing::FLIP_V, + f32::from(u8::from(!v)), + ); + self.history + .record(&self.graph, Edit::Action(labels::step::FLIP_V)); + } + + /// TRACES: FR-DEV-3 + /// The crop the user chose, as distinct from the one currently applied. + /// + /// The remembered intent, but only while it is still credible: if the + /// graph no longer holds what the auto-crop wrote, something else has set + /// the crop since — a handle, a ratio, a sidecar, a paste, an undo — and + /// that new rectangle *is* the intent. See [`Self::auto_crop`]. + pub(super) fn intended_crop(&self) -> CropRect { + match self.auto_crop { + Some((applied, intended)) if applied == self.graph.crop() => intended, + _ => self.graph.crop(), + } + } + + /// TRACES: FR-DEV-3 + /// Fit the crop to the area the straightening angle leaves defined. + /// + /// Turning a rectangle inside its own bounds exposes its corners: there is + /// no source pixel out there and the shader renders it black. Nothing in + /// the render prevents it — a free angle deliberately does *not* change the + /// output size, so that straightening a horizon leaves the frame where the + /// user put it — which is correct for the drag and leaves black wedges in + /// the corners of the finished photograph. + /// + /// This is the correction, and it runs when the gesture **finishes**. + /// Applied continuously it would fight the drag, shrinking the crop on + /// every frame of the slider. + /// + /// **It grows as well as shrinks.** The crop is recomputed from + /// [`Self::intended_crop`] rather than from itself, so straightening + /// further in takes more away and straightening back out gives it back, + /// stopping at the rectangle the user actually chose. Deriving it from the + /// applied crop instead — the obvious way, and how this first shipped — + /// ratchets: every angle the slider rested at takes its cut and none of + /// them is ever returned, so coming back to zero leaves a crop that + /// nothing on screen explains. + /// + /// The crop keeps its own shape — so a locked ratio survives — and keeps + /// the side of the frame it was on; see [`CropRect::fitted_into`] for why + /// it is not simply replaced by the inscribed rectangle. + pub fn auto_crop_to_angle(&mut self) { + let (sw, sh) = self.demosaiced.size(); + // At zero this is the whole frame, and fitting into it is the identity + // — which is what returns an over-corrected crop to its full size. + // There is deliberately no early exit for the upright case: that exit + // is precisely what would strand the crop small. + let bound = self.graph.framing().max_inscribed_crop(sw, sh); + let intended = self.intended_crop(); + let want = intended.fitted_into(bound); + + if want != self.graph.crop() { + self.graph.set_crop(want); + self.history + .record(&self.graph, Edit::Op(dr_pipeline::framing::ID)); + } + // Recorded even when nothing moved: the pairing is what tells the next + // call that this rectangle is a correction rather than a choice. + self.auto_crop = Some((want, intended)); + } + + /// Set the straightening angle, in degrees. + pub fn set_angle(&mut self, degrees: f32) { + self.graph.set_param( + dr_pipeline::framing::ID, + dr_pipeline::framing::ANGLE, + degrees, + ); + self.history.record( + &self.graph, + Edit::Param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE), + ); + } + + /// Whether the framing currently changes the image — what lights the + /// section's modified dot and enables its reset. + /// + /// Asks whether it *edits*, not whether it is active: a zoomed view makes + /// the framing active without changing the photograph, and a section that + /// claimed an edit because the user scrolled would be lying. + pub fn framing_edits_image(&self) -> bool { + self.graph.framing().edits_image() + } + + /// Return crop, straightening, rotation and flips to neutral, leaving + /// every colour adjustment alone. + /// + /// The zoom is deliberately preserved: it is a viewing state, and resetting + /// the framing is an edit, so throwing away where the user was looking + /// would be an unrelated second effect. + pub fn reset_framing(&mut self) { + let view = self.graph.framing().view(); + self.graph.framing_mut().reset(); + self.graph.framing_mut().set_view(view); + self.history + .record(&self.graph, Edit::Action(labels::step::RESET_FRAMING)); + } + + /// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×. + pub fn zoom(&self) -> f32 { + let v = self.graph.framing().view(); + if v.width <= 0.0 { + 1.0 + } else { + 1.0 / v.width + } + } + + pub fn is_zoomed(&self) -> bool { + self.graph.framing().is_zoomed() + } + + /// Zoom about a point, given in fractions of the *visible* area. + /// + /// Anchoring matters: zooming about the pointer keeps whatever is under + /// it stationary, which is what makes a scroll-wheel zoom feel like it is + /// magnifying the photograph rather than sliding it around. + /// + /// `factor` multiplies the current zoom — above 1 moves in. + pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) { + const MAX_ZOOM: f32 = 16.0; + + let view = self.graph.framing().view(); + let current = if view.width > 0.0 { + 1.0 / view.width + } else { + 1.0 + }; + let target = (current * factor).clamp(1.0, MAX_ZOOM); + // Snapped so scrolling back out reliably reaches "fit" rather than + // stopping a fraction short and leaving the image imperceptibly + // panned. + let target = if (target - 1.0).abs() < 0.01 { + 1.0 + } else { + target + }; + + let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0); + + // The point under the cursor, in framed coordinates, must land back + // under the cursor afterwards. + let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width; + let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height; + + self.set_view_clamped( + anchor_x - at_x.clamp(0.0, 1.0) * extent, + anchor_y - at_y.clamp(0.0, 1.0) * extent, + extent, + ); + } + + /// Pan by a fraction of the *visible* area — what a drag reports. + pub fn pan_by(&mut self, dx: f32, dy: f32) { + let view = self.graph.framing().view(); + self.set_view_clamped( + view.x + dx * view.width, + view.y + dy * view.height, + view.width, + ); + } + + /// Back to fitting the whole frame. + pub fn reset_zoom(&mut self) { + self.graph.framing_mut().set_view(CropRect::default()); + } + + /// TRACES: FR-UI-4 | FR-DSP-1 + /// The zoom that puts one source pixel under one screen pixel. + /// + /// **Derived from the file and the viewport rather than fixed at some + /// multiple**, because 1:1 is not a number: a 60 MP frame in a 1200px + /// viewport needs about 7× before its pixels are its own, and a + /// screen-sized JPEG needs none at all. The same arithmetic + /// [`Self::magnifies_source`] uses to decide how to *filter* the canvas, + /// asked in the other direction — which is what keeps the readout the + /// canvas shows and the zoom this lands on from disagreeing about what + /// 100% means. + /// + /// Never below 1.0: fitting is as far out as the view goes, so a + /// photograph already smaller than the viewport is at 1:1 the moment it + /// is fitted. + pub fn one_to_one_zoom(&self, viewport_w: u32, viewport_h: u32) -> f32 { + let (sw, sh) = self.demosaiced.size(); + let (fw, fh) = self.graph.output_size(sw, sh); + let (rw, _) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1)); + if rw == 0 { + return 1.0; + } + (fw as f32 / rw as f32).max(1.0) + } + + /// TRACES: FR-UI-4 + /// Where the view is centred, in fractions of the framed image. + /// + /// The form the inspection point is *remembered* in, and it has to be + /// this one: the point is carried to the next photograph, and fractions + /// of the frame are the only coordinates two different files share. + pub fn inspection_point(&self) -> (f32, f32) { + let v = self.graph.framing().view(); + (v.x + v.width / 2.0, v.y + v.height / 2.0) + } + + /// TRACES: FR-UI-4 + /// Put the view at 1:1, centred on a point in fractions of the framed + /// image. + /// + /// Separate from [`Self::toggle_inspection`] because the two callers are + /// not the same person: the toggle is a photographer pressing something, + /// and this is the next photograph arriving under the magnifier the last + /// one was left under. + pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) { + let extent = + (1.0 / self.one_to_one_zoom(viewport_w, viewport_h)).clamp(CropRect::MIN_EXTENT, 1.0); + self.set_view_clamped(x - extent / 2.0, y - extent / 2.0, extent); + } + + /// TRACES: FR-UI-4 | FR-DEV-3 + /// Toggle between fitting the frame and inspecting it at 1:1. + /// + /// **Why 1:1 and not "zoom in a bit".** Noise reduction and capture + /// sharpening are judgements about individual pixels, and at a fitted + /// view several source pixels are averaged into each screen pixel — so + /// the frame looks cleaner and softer than it is, and the photographer + /// corrects for a softness the display invented. Over-sharpening is the + /// documented result. There is exactly one magnification at which those + /// two controls are telling the truth, and this is the gesture that + /// reaches it without anyone reading a percentage. + /// + /// `at_x`/`at_y` are fractions of the *visible* area — the same + /// coordinates [`Self::zoom_about`] takes, because they come from the + /// same pointer over the same box. + /// + /// Returns the point now under inspection in fractions of the framed + /// image, or `None` where the view has gone back to fit. That is the + /// answer *after* clamping, so a point near an edge is remembered where + /// the view actually landed rather than where the finger was — otherwise + /// the next photograph would be inspected somewhere the previous one + /// never showed. + /// + /// Leaves the history alone, and must: this changes no pixel of the file. + /// See [`Self::framing_edits_image`] for the same distinction drawn from + /// the other side. + pub fn toggle_inspection( + &mut self, + at_x: f32, + at_y: f32, + viewport_w: u32, + viewport_h: u32, + ) -> Option<(f32, f32)> { + // Out from *any* zoom, not only from 1:1. The gesture means "show me + // the whole photograph again", and a scroll wheel that stopped at + // 173% must not leave the toggle inert. + if self.is_zoomed() { + self.reset_zoom(); + return None; + } + + let view = self.graph.framing().view(); + let x = view.x + at_x.clamp(0.0, 1.0) * view.width; + let y = view.y + at_y.clamp(0.0, 1.0) * view.height; + self.inspect_at(x, y, viewport_w, viewport_h); + Some(self.inspection_point()) + } + + /// Place a square view of `extent`, keeping it inside the frame. + /// + /// Clamped rather than allowed to run off the edge: panning past the + /// boundary would show undefined area beside the photograph, which reads + /// as a rendering fault rather than as the end of the image. + pub(super) fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) { + let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0); + let max = 1.0 - extent; + self.graph.framing_mut().set_view(CropRect { + x: x.clamp(0.0, max.max(0.0)), + y: y.clamp(0.0, max.max(0.0)), + width: extent, + height: extent, + }); + } + + /// The largest centred crop that, at the current straightening angle, + /// contains no undefined area. What a "straighten and fill" action + /// applies. + pub fn max_inscribed_crop(&self) -> CropRect { + let (w, h) = self.demosaiced.size(); + self.graph.framing().max_inscribed_crop(w, h) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::develop::test_support::*; + + // --- the crop ratio lock --------------------------------------------- + + #[test] + fn a_locked_ratio_is_resolved_in_output_pixels() { + // 3:2 means three pixels across to two down, whatever shape the frame + // it is being cut out of happens to be. + let landscape = CropAspect::Fixed(3, 2); + assert_eq!(landscape.ratio((6000, 4000), false), Some(1.5)); + assert_eq!(landscape.ratio((4000, 6000), false), Some(1.5)); + // Stood on its short edge. + assert_eq!(landscape.ratio((6000, 4000), true), Some(2.0 / 3.0)); + } + + #[test] + fn the_frames_own_ratio_follows_the_frame() { + // What separates `Original` from naming the same numbers: it is right + // on the next photograph from another body, and after a quarter turn. + let a = CropAspect::Original; + assert_eq!(a.ratio((6000, 4000), false), Some(1.5)); + assert_eq!(a.ratio((4000, 6000), false), Some(2.0 / 3.0)); + assert_eq!(a.ratio((5000, 5000), false), Some(1.0)); + } + + #[test] + fn free_locks_nothing() { + assert_eq!(CropAspect::Free.ratio((6000, 4000), false), None); + assert_eq!(CropAspect::Free.ratio((6000, 4000), true), None); + assert!(!CropAspect::Free.has_orientation()); + } + + #[test] + fn a_square_has_no_second_orientation() { + // Turning it would be a control that visibly does nothing, so the + // switch is disabled and the flag is ignored either way. + let square = CropAspect::Fixed(1, 1); + assert!(!square.has_orientation()); + assert_eq!(square.ratio((6000, 4000), true), Some(1.0)); + assert_eq!(square.ratio((6000, 4000), false), Some(1.0)); + } + + #[test] + fn only_a_named_ratio_has_to_be_turned_with_the_frame() { + // The distinction that stops `Original` being flipped twice: a quarter + // turn swaps the frame's axes, so a ratio resolved *against* the frame + // has already turned by the time anything asks it. + assert!(CropAspect::Fixed(16, 9).turns_with_the_frame()); + assert!(CropAspect::Fixed(3, 2).turns_with_the_frame()); + assert!(!CropAspect::Original.turns_with_the_frame()); + assert!(!CropAspect::Free.turns_with_the_frame()); + assert!(!CropAspect::Fixed(1, 1).turns_with_the_frame()); + } + + #[test] + fn a_quarter_turn_leaves_a_locked_crop_the_shape_it_already_was() { + // The whole reason the switch is flipped on a quarter turn. A crop + // locked to 16:9 is carried through the turn by `rotate_crop`, coming + // out at 9:16 of a frame whose axes have also swapped — so the lock + // must now read as portrait, or the next drag would snap the crop back + // upright and undo what the turn did to the composition. + let (fw, fh) = (6000u32, 4000u32); + let aspect = CropAspect::Fixed(16, 9); + let before = CropRect::default().with_aspect( + fw, + fh, + aspect.ratio((fw, fh), false).unwrap(), + (0.5, 0.5), + ); + + let after = rotate_crop(before, 1); + let (tw, th) = (fh, fw); + let got = (after.width * tw as f32) / (after.height * th as f32); + let want = aspect.ratio((tw, th), true).unwrap(); + assert!( + (got / want - 1.0).abs() < 1e-3, + "turned crop is {got}, the flipped lock says {want}" + ); + } + + #[test] + fn every_offered_ratio_has_a_name_and_a_place() { + // The chips are drawn from this list, so a duplicate would light two + // at once and an empty label would draw a blank button. + let mut seen = Vec::new(); + for a in CropAspect::CHOICES { + assert!(!a.label().is_empty(), "{a:?} has no label"); + assert!(!seen.contains(&a), "{a:?} is offered twice"); + seen.push(a); + } + assert_eq!( + CropAspect::CHOICES[0], + CropAspect::Free, + "free is the default" + ); + assert_eq!(CropAspect::default(), CropAspect::Free); + } + + /// TRACES: FR-UI-4 + /// The inspection zoom is 1:1 for *this* file in *this* viewport. + /// + /// The number is the whole point. A magnifier that lands on some fixed + /// multiple tells the photographer nothing about whether they are looking + /// at the file's own pixels, and that is the only question noise reduction + /// and capture sharpening can honestly be judged by. + #[test] + fn inspecting_lands_on_one_source_pixel_per_screen_pixel() { + let Some(ctx) = headless() else { return }; + let (mut session, _) = grey_session(&ctx); + + // Sixty-four source pixels fitted into thirty-two is one screen pixel + // per two of the file's, so 1:1 is 2×. + let one_to_one = session.one_to_one_zoom(32, 32); + assert!( + (one_to_one - 2.0).abs() < 1e-3, + "a 64px frame in a 32px viewport is 2× at 1:1, not {one_to_one}" + ); + + assert!( + session.toggle_inspection(0.5, 0.5, 32, 32).is_some(), + "the first toggle goes in" + ); + assert!( + (session.zoom() - one_to_one).abs() < 1e-3, + "the view should have landed on 1:1, not {}", + session.zoom() + ); + } + + /// TRACES: FR-UI-4 + /// The second press goes back to fit — from any zoom, not only from 1:1. + /// + /// A scroll wheel that stopped at 173% must not leave the toggle inert: + /// the gesture means "show me the whole photograph again". + #[test] + fn the_inspection_toggle_returns_to_fit_from_any_zoom() { + let Some(ctx) = headless() else { return }; + let (mut session, _) = grey_session(&ctx); + + session.zoom_about(3.0, 0.5, 0.5); + assert!(session.is_zoomed(), "the premise"); + + assert_eq!( + session.toggle_inspection(0.5, 0.5, 32, 32), + None, + "toggling out reports no inspection point" + ); + assert!(!session.is_zoomed()); + } + + /// TRACES: FR-UI-4 | FR-DEV-5 + /// Inspecting is a way of looking, and leaves no trace on the photograph. + /// + /// The failure this guards is quiet and expensive: a zoom that recorded a + /// step would put a viewport rectangle on the undo stack and into the + /// sidecar, and the photograph would then open on another device cropped + /// to wherever somebody once looked. + #[test] + fn inspecting_writes_nothing_the_file_would_remember() { + let Some(ctx) = headless() else { return }; + let (mut session, _) = grey_session(&ctx); + assert!(!session.can_undo(), "the premise: nothing has been done"); + + session.toggle_inspection(0.25, 0.75, 32, 32); + + assert!(!session.can_undo(), "a zoom is not a step to take back"); + assert!(session.is_neutral(), "and it is not an edit either"); + assert!(!session.framing_edits_image()); + } + + /// The whole scroll-to-zoom path, end to end, in the order the user drives + /// it: show the image fitted, *then* turn the wheel. + /// + /// The lower layers each had zoom tests and each passed while this was + /// broken, because every one of them set a view before its first render. + /// That ordering hid the bug — a neutral framing compiles a prologue that + /// never reads the crop rect, and while zoom was absent from the structure + /// hash that pipeline stayed cached once zoomed. The session reported the + /// new zoom, the uniforms carried the new view, and the pixels never moved. + /// + /// So this asserts on the rendered pixels rather than on `zoom()`: the + /// symptom was precisely that the state was right and the image was not. + #[test] + fn zooming_after_a_fitted_render_changes_the_pixels() { + let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { + log::warn!("no GPU adapter; skipping"); + return; + }; + + // A gradient, so any change in the sampled region moves the pixels. + let (w, h) = (64u32, 64u32); + let mut rgba = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]); + } + } + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL) + .expect("session"); + + let fitted = session.render(64, 64).expect("fitted render"); + session.zoom_about(4.0, 0.5, 0.5); + assert!(session.is_zoomed(), "the session did not register the zoom"); + let zoomed = session.render(64, 64).expect("zoomed render"); + + // Both images are still readable here because consecutive frames go to + // alternating textures; see `AdjustPass::targets`. Holding two frames + // at once would be meaningless against a single reused target. + let before = read_back(&ctx, &fitted); + let after = read_back(&ctx, &zoomed); + let differing = before + .iter() + .zip(after.iter()) + .filter(|(a, b)| a != b) + .count(); + + assert!( + differing > 0, + "zooming 4x after a fitted render produced identical pixels — the \ + view reached the session but not the shader" + ); + } + + #[test] + fn magnification_follows_the_source_resolution_and_not_the_zoom_factor() { + // What decides whether the canvas is filtered. The distinction this + // guards is the reason the interface cannot answer it from `zoom()` + // alone: the same 4x on a large source is still showing more source + // pixels than screen pixels, while on a small one it is already + // inventing values between them. + let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { + log::warn!("no GPU adapter; skipping"); + return; + }; + + // Bigger than the viewport it is shown in: `fit` scales it down, so + // every screen pixel still has several source pixels behind it. + let big = vec![128u8; (800 * 800 * 4) as usize]; + let mut session = + DevelopSession::open_rgb(&ctx, &big, 800, 800, dr_types::Orientation::NORMAL) + .expect("session"); + assert!( + !session.magnifies_source(200, 200), + "a downscaled image is not magnified" + ); + session.zoom_about(2.0, 0.5, 0.5); + assert!( + !session.magnifies_source(200, 200), + "2x on a 4x-downscaled source is still below 1:1" + ); + session.zoom_about(8.0, 0.5, 0.5); + assert!( + session.magnifies_source(200, 200), + "16x on a 4x-downscaled source magnifies and must not be filtered" + ); + + // Smaller than the viewport: `fit` refuses to upscale, so the render is + // 1:1 and unzoomed is exactly the boundary — not past it. + let small = vec![128u8; (100 * 100 * 4) as usize]; + let mut session = + DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL) + .expect("session"); + assert!( + !session.magnifies_source(800, 800), + "1:1 is the boundary, not past it — filtering must not flip on a \ + rounding error" + ); + session.zoom_about(2.0, 0.5, 0.5); + assert!( + session.magnifies_source(800, 800), + "any zoom past a 1:1 render magnifies" + ); + } + + #[test] + fn four_quarter_turns_return_a_crop_where_it_started() { + // The property that makes rotation safe to repeat: a user who turns + // past the orientation they wanted and keeps going must arrive back at + // the crop they had, not at a slowly drifting one. + let start = CropRect { + x: 0.1, + y: 0.2, + width: 0.3, + height: 0.4, + }; + let mut r = start; + for _ in 0..4 { + r = rotate_crop(r, 1); + } + assert!((r.x - start.x).abs() < 1e-5, "x drifted to {}", r.x); + assert!((r.y - start.y).abs() < 1e-5, "y drifted to {}", r.y); + assert!((r.width - start.width).abs() < 1e-5); + assert!((r.height - start.height).abs() < 1e-5); + } + + #[test] + fn a_quarter_turn_exchanges_a_crops_extents() { + // A portrait selection on a landscape frame must come out landscape. + // Were the extents left alone, the rect would keep its old shape while + // the frame changed to the other one, and the crop would spill off the + // photograph. + let r = rotate_crop( + CropRect { + x: 0.0, + y: 0.0, + width: 0.25, + height: 1.0, + }, + 1, + ); + assert!((r.width - 1.0).abs() < 1e-5, "width was {}", r.width); + assert!((r.height - 0.25).abs() < 1e-5, "height was {}", r.height); + } + + #[test] + fn rotating_a_crop_keeps_it_inside_the_frame() { + // Whatever the angle and wherever the rect, the result must still be a + // rect the pipeline can render: outside the unit square it would + // sample undefined area, and degenerate it is a zero-sized texture. + for turns in -5..=5 { + for rect in [ + CropRect { + x: 0.0, + y: 0.0, + width: 1.0, + height: 1.0, + }, + CropRect { + x: 0.7, + y: 0.8, + width: 0.3, + height: 0.2, + }, + CropRect { + x: 0.0, + y: 0.45, + width: 0.02, + height: 0.02, + }, + ] { + let r = rotate_crop(rect, turns); + assert!( + r.x >= 0.0 && r.y >= 0.0, + "{turns} turns of {rect:?} gave {r:?}" + ); + assert!( + r.x + r.width <= 1.0 + 1e-5 && r.y + r.height <= 1.0 + 1e-5, + "{turns} turns of {rect:?} left the frame: {r:?}" + ); + assert!( + r.width >= CropRect::MIN_EXTENT && r.height >= CropRect::MIN_EXTENT, + "{turns} turns of {rect:?} went degenerate: {r:?}" + ); + } + } + } + + #[test] + fn opposite_quarter_turns_cancel() { + // The rotate-left and rotate-right buttons must undo one another, or + // correcting an over-rotation would land somewhere new each time. + let start = CropRect { + x: 0.15, + y: 0.05, + width: 0.5, + height: 0.25, + }; + let there_and_back = rotate_crop(rotate_crop(start, 1), -1); + assert!((there_and_back.x - start.x).abs() < 1e-5); + assert!((there_and_back.y - start.y).abs() < 1e-5); + assert!((there_and_back.width - start.width).abs() < 1e-5); + assert!((there_and_back.height - start.height).abs() < 1e-5); + } + + #[test] + fn a_full_crop_survives_rotation_as_a_full_crop() { + // The common case: rotating an uncropped photograph must not quietly + // introduce a crop, which would shrink the exported image. + assert!(rotate_crop(CropRect::default(), 1).is_full()); + assert!(rotate_crop(CropRect::default(), -3).is_full()); + } +} diff --git a/ui/dr-ui/src/develop/history.rs b/ui/dr-ui/src/develop/history.rs new file mode 100644 index 0000000..31ac800 --- /dev/null +++ b/ui/dr-ui/src/develop/history.rs @@ -0,0 +1,401 @@ +//! Named snapshots and the undo/redo stack. +use dr_pipeline::Edit; + +use crate::labels; + +use super::session::DevelopSession; + +impl DevelopSession { + // ---- named snapshots --------------------------------------------------- + + /// TRACES: FR-DEV-5 + /// Hand the session the snapshots its sidecar holds. Called once, on + /// open, beside [`Self::apply_version`]. + pub fn set_snapshots(&mut self, snapshots: Vec) { + self.snapshots = snapshots; + self.removed_snapshots.clear(); + } + + /// The snapshots as they stand, oldest first. + pub fn snapshots(&self) -> &[dr_pipeline::Version] { + &self.snapshots + } + + /// The ids deleted this sitting, for the save. + pub fn removed_snapshots(&self) -> &[String] { + &self.removed_snapshots + } + + /// TRACES: FR-DEV-5 + /// Name the state the photograph is in, and keep it. Returns the id. + /// + /// Not a history step: taking a snapshot changes nothing about the edit, + /// and an undo that removed one would be undoing a decision to remember + /// rather than a change to the photograph. Deleting one is the same. + /// + /// The id is stamped with the second and a per-process random word + /// rather than counted, because two devices can each take a snapshot of + /// the same photograph and both have to survive the merge — which keys + /// on this id, and would fold two `snap-3`s into one. + pub fn take_snapshot(&mut self, name: &str) -> String { + use std::hash::{BuildHasher, Hasher}; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let salt = std::collections::hash_map::RandomState::new() + .build_hasher() + .finish(); + let id = format!("snap-{now}-{:08x}", salt as u32); + + let name = name.trim(); + let name = if name.is_empty() { + format!("Snapshot {}", self.snapshots.len() + 1) + } else { + name.to_string() + }; + let mut version = dr_pipeline::Version::from_graph(id.clone(), name, &self.graph); + // The stack with the model's coverage folded in, for the reason the + // save uses it: a subject layer stored by identity alone renders as + // nothing until a model is run, and a snapshot restored on the other + // device, or in a batch export, never gets one. + version.masks = self.masks_for_storage(); + version.modified = now; + self.snapshots.push(version); + id + } + + /// TRACES: FR-DEV-5 + /// Put the photograph back the way a snapshot has it. One history step, + /// so it is undoable as a whole, exactly as a paste is. + pub fn restore_snapshot(&mut self, id: &str) -> bool { + let Some(version) = self.snapshots.iter().find(|v| v.uuid == id).cloned() else { + return false; + }; + let rebake = version.apply(&mut self.graph); + self.pay_film_debt(&rebake); + self.history + .record(&self.graph, Edit::Action(labels::step::SNAPSHOT_RESTORED)); + true + } + + /// TRACES: FR-DEV-5 + pub fn rename_snapshot(&mut self, id: &str, name: &str) { + let name = name.trim(); + if name.is_empty() { + return; + } + if let Some(v) = self.snapshots.iter_mut().find(|v| v.uuid == id) { + v.name = name.to_string(); + } + } + + /// TRACES: FR-DEV-5 + /// Forget a snapshot. Remembered as a deletion so the save takes it out + /// of the file rather than merely not putting it back. + pub fn delete_snapshot(&mut self, id: &str) { + let before = self.snapshots.len(); + self.snapshots.retain(|v| v.uuid != id); + if self.snapshots.len() != before { + self.removed_snapshots.push(id.to_string()); + } + if self.compared_snapshot.as_deref() == Some(id) { + self.compared_snapshot = None; + } + } + + /// TRACES: FR-DEV-7 + /// Hold a comparison against a snapshot, or let it go. Returns whether + /// anything changed, so a repeat costs no render. + pub fn compare_snapshot(&mut self, id: Option<&str>) -> bool { + let id = id.filter(|id| self.snapshots.iter().any(|v| v.uuid == *id)); + if self.compared_snapshot.as_deref() == id { + return false; + } + self.compared_snapshot = id.map(str::to_string); + true + } + + /// The snapshot being held against the edit, if one is. + pub fn compared_snapshot(&self) -> Option<&str> { + self.compared_snapshot.as_deref() + } + + /// TRACES: FR-DEV-7 + /// Render the photograph as a snapshot has it, without becoming it. + /// + /// The same suspension [`Self::render_original`] uses — borrow the graph + /// for one render and hand it back — because it is the same question + /// about a different reference point: "the version I liked twenty + /// minutes ago" instead of the file. Nothing is recorded and nothing is + /// marked modified. A held comparison against a snapshot that has since + /// been deleted falls back to the edit itself, which is what is on + /// screen anyway. + pub fn render_compared(&mut self, width: u32, height: u32) -> Result { + let Some(version) = self + .compared_snapshot + .as_deref() + .and_then(|id| self.snapshots.iter().find(|v| v.uuid == id)) + .cloned() + else { + return self.render(width, height); + }; + let saved = self.graph.state(); + let debt = version.apply(&mut self.graph); + self.pay_film_debt(&debt); + let rendered = self.render(width, height); + let debt = self.graph.set_state(&saved); + self.pay_film_debt(&debt); + rendered + } + + /// TRACES: FR-DEV-5 + /// The snapshot list as the panel draws it, oldest first. + pub fn snapshot_rows(&self) -> Vec { + self.snapshots + .iter() + .map(|v| crate::SnapshotRow { + id: v.uuid.as_str().into(), + name: v.name.as_str().into(), + comparing: self.compared_snapshot.as_deref() == Some(v.uuid.as_str()), + }) + .collect() + } + + /// TRACES: FR-DEV-5 + /// [`Self::pay_film_debt`] for a history step, when the step went + /// anywhere. + /// + /// The guard is the whole difference between the two: a step that found + /// nowhere to go left the graph alone, and clearing the film because + /// undo hit the floor would take the picture's stock off it. + pub(super) fn settle(&mut self, step: &dr_pipeline::Step) { + if let dr_pipeline::Step::Took(rebake) = step { + self.pay_film_debt(rebake); + } + } + + /// TRACES: FR-DEV-5 + /// Step the edit back one, returning whether anything moved. + /// + /// The panel must be rebuilt from [`Self::rows`] afterwards, for the same + /// reason a paste must: this moves values the controls are showing and + /// nothing here pushes them. + pub fn undo(&mut self) -> bool { + let step = self.history.undo(&mut self.graph); + self.settle(&step); + step.moved() + } + + /// TRACES: FR-DEV-5 + /// Step the edit forward one, returning whether anything moved. + pub fn redo(&mut self) -> bool { + let step = self.history.redo(&mut self.graph); + self.settle(&step); + step.moved() + } + + pub fn can_undo(&self) -> bool { + self.history.can_undo() + } + + pub fn can_redo(&self) -> bool { + self.history.can_redo() + } + + /// TRACES: FR-DEV-5 | FR-DEV-7 + /// Every step this photograph has been through, newest first. + /// + /// Newest first because the list is consulted to take back something just + /// done, not browsed chronologically — the order `dr_catalog::trash` + /// settled on for the same question. It also keeps the interesting end + /// against the heading, so a stack sixty-four deep does not put the step + /// the photographer is looking for at the bottom of a long scroll. + /// + /// The reversal happens here rather than in the core, which returns the + /// stack in stack order and stamps each row with its own index — so + /// nothing on this side does arithmetic to turn a row back into a step. + pub fn history_rows(&self) -> Vec { + let mut rows: Vec<_> = self + .history + .entries(&self.graph) + .into_iter() + .map(|entry| crate::HistoryRow { + index: entry.index as i32, + label: labels::resolve(entry.label.0).into(), + current: entry.current, + // Everything past the mark is a future the photographer + // stepped out of. Still listed, because it is still reachable + // by redo and hiding it would make redo arrive somewhere the + // panel never mentioned — but drawn as the branch it is. + undone: entry.index > self.history.cursor(), + }) + .collect(); + rows.reverse(); + rows + } + + /// TRACES: FR-DEV-5 | FR-DEV-7 + /// Step straight to one row of [`Self::history_rows`]. + /// + /// Takes the row's own `index`, not its position in that list. + /// TRACES: FR-DEV-5 + /// A number that changes exactly when [`Self::history_rows`] would. + /// + /// The panel is rebuilt off this rather than every redraw: a drag ends in + /// a redraw per frame and changes no row, and pushing a fresh model makes + /// the toolkit tear down and recreate every one of them. + pub fn history_revision(&self) -> u64 { + self.history.revision() + } + + pub fn go_to_history(&mut self, index: i32) -> bool { + let Ok(index) = usize::try_from(index) else { + return false; + }; + let step = self.history.go_to(&mut self.graph, index); + self.settle(&step); + step.moved() + } + + /// TRACES: FR-DEV-5 + /// What undo would take back, and what redo would put back. + /// + /// Named on the buttons rather than left to the bare verb. "Undo" asks the + /// photographer to remember what they last did, which after a run of small + /// adjustments is exactly what they have stopped tracking — and it is the + /// moment they are least willing to press a button and find out. + /// + /// Empty when there is nowhere to go, so the caller falls back to the verb + /// alone rather than printing a label for a disabled control. + pub fn undo_label(&self) -> String { + // Undo takes back the step the graph is *standing on*, so the row to + // name is the current one — not the one it will land on. + self.step_name(self.history.cursor(), self.history.can_undo()) + } + + /// TRACES: FR-DEV-5 + pub fn redo_label(&self) -> String { + self.step_name(self.history.cursor() + 1, self.history.can_redo()) + } + + pub(super) fn step_name(&self, index: usize, offered: bool) -> String { + if !offered { + return String::new(); + } + self.history + .entries(&self.graph) + .into_iter() + .find(|e| e.index == index) + .map(|e| labels::resolve(e.label.0)) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use crate::develop::test_support::*; + + /// TRACES: FR-DEV-5 + /// A snapshot is a state the photographer named: taking one changes + /// nothing, going back to it is one step, and undo takes the whole of + /// that step back. + #[test] + fn a_snapshot_is_restored_as_one_step_and_undone_as_one() { + let Some(ctx) = headless() else { return }; + let (mut session, _) = grey_session(&ctx); + + let rows = session.rows(); + let row = rows + .iter() + .find(|row| { + session.set_param(row.op_index, row.param_index, row.maximum); + !session.is_neutral() + }) + .expect("some control in the panel moves the picture") + .clone(); + let liked = session.copy_settings(); + let steps_before = session.history_rows().len(); + + let id = session.take_snapshot("Liked this"); + assert_eq!(session.snapshots().len(), 1); + assert_eq!(session.snapshots()[0].name, "Liked this"); + assert_eq!( + session.history_rows().len(), + steps_before, + "naming a state is not a change to the photograph" + ); + + // Move on, then go back. + session.set_param(row.op_index, row.param_index, row.minimum); + let moved_on = session.copy_settings(); + assert_ne!(moved_on, liked, "the premise: the edit has moved"); + let steps_moved = session.history_rows().len(); + + assert!(session.restore_snapshot(&id)); + assert_eq!(session.copy_settings(), liked, "back to the named state"); + assert_eq!( + session.history_rows().len(), + steps_moved + 1, + "restoring is one step" + ); + + assert!(session.undo()); + assert_eq!( + session.copy_settings(), + moved_on, + "and undo takes the whole restore back" + ); + + // A name nobody typed is numbered rather than blank. + session.take_snapshot(" "); + assert_eq!(session.snapshots()[1].name, "Snapshot 2"); + + session.delete_snapshot(&id); + assert_eq!(session.snapshots().len(), 1); + assert_eq!(session.removed_snapshots(), [id.as_str()]); + } + + /// TRACES: FR-DEV-7 | FR-DEV-5 + /// Holding a snapshot against the edit is the same bargain as holding + /// the original: the picture changes, and nothing else does. + #[test] + fn comparing_against_a_snapshot_leaves_the_edit_exactly_as_it_was() { + let Some(ctx) = headless() else { return }; + let (mut session, _) = grey_session(&ctx); + + let rows = session.rows(); + let row = rows + .iter() + .find(|row| { + session.set_param(row.op_index, row.param_index, row.maximum); + !session.is_neutral() + }) + .expect("some control in the panel moves the picture") + .clone(); + let id = session.take_snapshot("Bright"); + session.set_param(row.op_index, row.param_index, row.minimum); + let edit = session.copy_settings(); + let steps = session.history_rows().len(); + + assert!(session.compare_snapshot(Some(&id)), "the hold began"); + assert!( + !session.compare_snapshot(Some(&id)), + "a repeat of the same hold is not a change" + ); + assert_eq!(session.compared_snapshot(), Some(id.as_str())); + session + .render_compared(64, 64) + .expect("render the snapshot"); + + assert_eq!(session.copy_settings(), edit, "every parameter comes back"); + assert_eq!(session.history_rows().len(), steps, "looking is not a step"); + + assert!(session.compare_snapshot(None), "and letting go is one"); + assert!(session.compared_snapshot().is_none()); + assert!( + !session.compare_snapshot(Some("nothing-by-this-name")), + "a snapshot that does not exist cannot be held" + ); + } +} diff --git a/ui/dr-ui/src/develop/mask_ops.rs b/ui/dr-ui/src/develop/mask_ops.rs new file mode 100644 index 0000000..8315923 --- /dev/null +++ b/ui/dr-ui/src/develop/mask_ops.rs @@ -0,0 +1,1377 @@ +//! Creating and adjusting mask layers, and the machinery that turns a mask +//! stack into the rasterised arrays a render pass samples -- including what +//! gets written back to the sidecar as stored coverage. +use std::borrow::Cow; +use std::sync::Arc; + +use dr_gpu::GpuContext; +use dr_pipeline::mask::{MaskLayer, MaskSource}; +use dr_pipeline::Edit; +#[cfg(test)] +use dr_pipeline::{EditGraph, ParamId}; + +use crate::labels; +use crate::segmentation; + +use super::session::DevelopSession; +use super::SEGMENT_PROXY_EDGE; + +/// `dr_pipeline`'s morphology, as `dr_segment` names it. +/// +/// Two enums for one idea, and deliberately: `dr-pipeline` describes the +/// *edit* and `dr-segment` implements the *transform*, and neither depends on +/// the other. The crossing is this function, which the compiler makes +/// exhaustive on both sides. +fn morphology_for(m: dr_pipeline::mask::Morphology) -> dr_segment::Morphology { + use dr_pipeline::mask::Morphology as Edit; + use dr_segment::Morphology as Transform; + match m { + Edit::None => Transform::None, + Edit::Dilate => Transform::Dilate, + Edit::Erode => Transform::Erode, + Edit::Close => Transform::Close, + Edit::Open => Transform::Open, + } +} + +impl DevelopSession { + /// Returns whether the array is now valid for the current stack. + /// + /// Split from reading the array back because the render below needs + /// `self.adjust` mutably while holding `self.masks` immutably. Those are + /// disjoint fields and the borrow checker will allow it — but only when + /// each is reached directly rather than through a method taking `self`. + /// What the uploaded distance fields depend on. + /// + /// The instance each layer names, and the morphology that *rebuilds* a + /// field rather than offsetting it. Nothing else: see `subject_key`. + pub(super) fn subject_signature(&self) -> u64 { + use dr_pipeline::mask::{MaskSource, Morphology}; + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut mix = |v: u64| { + for byte in v.to_le_bytes() { + h ^= byte as u64; + h = h.wrapping_mul(0x1000_0000_01b3); + } + }; + + // TRACES: FR-DEV-19c + // The reveal is in the key because it is in the *sequence*: revealing + // a layer with no adjustment on it gives that layer a slot, which + // renumbers every field after it. Leaving it out is the bug where + // clicking a subject shows the mask of whichever layer happened to be + // beneath it. + let reveal = self.reveal(); + mix(reveal.as_ref().map_or(0, |r| { + // Every shown id, not only which are shown: a layer joining the + // shown set renumbers every slot after it, exactly as one joining + // the active set does. Colours are left out — they change what + // the shader draws, not which field it draws through. + let mut h: u64 = 1; + for l in &r.layers { + for b in l.layer.as_bytes() { + h = h.wrapping_mul(31).wrapping_add(*b as u64); + } + h = h.wrapping_mul(31).wrapping_add(0x1f); + } + h + })); + + for layer in self.graph.masks().rendered(reveal.as_ref()) { + match &layer.base().source { + MaskSource::Subject { index, .. } => { + mix(1); + mix(*index as u64); + if layer.base().morphology.is_compound() { + mix(match layer.base().morphology { + Morphology::Close => 2, + Morphology::Open => 3, + _ => 0, + }); + mix(layer.base().morph_radius.to_bits() as u64); + } + } + // Hashed by *name*, and `4` rather than `1` so a category + // named the same as an instance index could never collide with + // it. The field has to be rebuilt when either changes. + MaskSource::Category { name, .. } => { + mix(4); + for b in name.as_bytes() { + mix(*b as u64); + } + // Only the compound operations change the field itself. + if layer.base().morphology.is_compound() { + mix(match layer.base().morphology { + Morphology::Close => 2, + Morphology::Open => 3, + _ => 0, + }); + mix(layer.base().morph_radius.to_bits() as u64); + } + // The refine control changes which pixels are in the mask + // at all, so it changes the coverage the field is measured + // from — unlike a feather, which is read off a field that + // is already correct. Omitting it here is the bug where + // the slider moves and nothing happens until some other + // control happens to invalidate the cache. + mix(layer.base().refine.to_bits() as u64); + } + _ => mix(0), + } + } + h + } + + /// One layer's coverage: what the model says now, or what the sidecar + /// remembered it saying. + /// + /// **The model first, always.** It is the live answer, it is the only one + /// that can respond to the refine control, and a run in this sitting is by + /// definition newer than anything a file was holding. + /// + /// The fallback is the point of the stored raster. A photograph reopened, + /// and a batch export from the grid — which opens a session, applies a + /// version and never runs a model at all — have no segmentation to ask, so + /// before this they resolved every subject and category layer to nothing + /// and wrote out a file missing the local adjustments, with a line in the + /// log as the only sign. See [`dr_pipeline::coverage`]. + /// + /// `None` for every other source: a gradient and a brush are rasterised + /// from their own geometry and have no coverage to fetch, and the caller + /// gives them a placeholder field so the slot indices still line up. + pub(super) fn layer_coverage<'a>( + &'a self, + layer: &'a dr_pipeline::mask::MaskLayer, + width: usize, + height: usize, + ) -> Option> { + use dr_pipeline::mask::MaskSource; + + let live = self + .segmentation + .as_ref() + .and_then(|seg| match &layer.base().source { + MaskSource::Category { name, .. } => { + seg.category_mask_at(name, layer.base().refine) + } + MaskSource::Subject { index, .. } => { + seg.instance_mask(*index as usize).map(Cow::Borrowed) + } + _ => None, + }); + if live.is_some() { + return live; + } + + match layer.base().source { + // Resampled where it was written against a different proxy edge; + // in the ordinary case the sizes match and this is the decode. + MaskSource::Subject { .. } | MaskSource::Category { .. } => layer + .base() + .coverage + .as_ref() + .map(|stored| Cow::Owned(stored.decode_at(width, height))), + _ => None, + } + } + + /// TRACES: FR-CAT-8 | FR-DEV-3 + /// The mask stack as it should be written to a sidecar. + /// + /// The stack the graph holds, with each model layer's coverage brought up + /// to what the segmentation now says it is. That raster is what lets the + /// *next* opening of this photograph render the layer without a model run + /// — the whole of [`dr_pipeline::coverage`]'s reason to exist. + /// + /// # Why here, and not where the field is built + /// + /// `ensure_subject_fields` is the tempting place: it is the one funnel + /// every coverage passes through, so recording it there would catch every + /// route automatically. But it runs on a *drag* — dilating a mask with a + /// compound morphology rebuilds the field every frame — and encoding a + /// megapixel raster per frame is exactly the kind of work NFR-P5 is about. + /// + /// Saving happens when the photograph stops being the open one, once, and + /// already costs a network round trip. So the encoding is done here, where + /// nothing is waiting on it, and the session's own rendering goes on + /// reading the model directly. + /// + /// Returns the stack by value rather than mutating: the caller is + /// serialising, not editing, and a graph that quietly gained a field on + /// the way past would be a mutation nobody asked for and undo would not + /// know about. + pub fn masks_for_storage(&self) -> dr_pipeline::mask::MaskStack { + use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS}; + use dr_pipeline::mask::MaskSource; + + let mut stack = self.graph.masks().clone(); + let Some(seg) = self.segmentation.as_ref() else { + // No model has run this sitting, so whatever the layers arrived + // holding is still the best answer anyone has. Handing the stack + // back untouched is what stops a photograph that was opened, + // glanced at and closed from losing the coverage its own sidecar + // gave it. + return stack; + }; + let (pw, ph) = seg.proxy_size(); + + for layer in stack.layers_mut() { + let values = match &layer.base().source { + MaskSource::Category { name, .. } => { + seg.category_mask_at(name, layer.base().refine) + } + MaskSource::Subject { index, .. } => { + seg.instance_mask(*index as usize).map(Cow::Borrowed) + } + // Nothing else has a model behind it. Left alone rather than + // cleared, so a hand-written file's key survives a round trip + // even though nothing samples it. + _ => continue, + }; + // The layer names something this run does not contain — a stale + // index, a category the scene model no longer offers. Keeping what + // was stored is right: it is a mask that was once correct, and the + // panel is already telling the user the layer is stale. + let Some(values) = values else { continue }; + + // `None` from the encoder means "will not fit in a sidecar", and + // the stored raster is then cleared rather than left standing. It + // would describe the layer at some earlier refine, and a mask of + // the wrong shape presented as authoritative is worse than the + // honest state, which is that this one needs the model run. + layer.base_mut().coverage = + Coverage::encode(&values, pw, ph, RENDERED_LEVELS).map(Arc::new); + } + + stack + } + + /// Rebuild the distance fields if anything they depend on moved. + pub(super) fn ensure_subject_fields(&mut self, ctx: &GpuContext) { + let key = self.subject_signature(); + if key == self.subject_key && self.subjects.is_some() { + return; + } + + // The proxy the fields are measured in: the segmentation's own where + // one has been run, and otherwise the size the mask array is + // rasterised at. `mask_raster_size` derives that from the photograph + // rather than from a segmentation for exactly this case, and the two + // are the same number by construction — see there. + let (pw, ph) = match self.segmentation.as_ref() { + Some(seg) => seg.proxy_size(), + None => { + let (w, h) = self.mask_raster_size(); + (w as usize, h as usize) + } + }; + + // In `rendered()` order, because that is the order the rasteriser + // walks and the order it indexes these by — the revealed layer + // included, which is why the reveal is asked for here and folded into + // the key above. + let reveal = self.reveal(); + let mut fields: Vec> = Vec::new(); + for layer in self.graph.masks().rendered(reveal.as_ref()) { + let field = match self.layer_coverage(layer, pw, ph) { + Some(coverage) => { + dr_segment::Shaped::build( + &coverage, + pw, + ph, + 128, + morphology_for(layer.base().morphology), + // Radii are fractions of the shorter edge; the field + // is in proxy pixels. + layer.base().morph_radius * pw.min(ph) as f32, + ) + .distance + } + // Full size, never `unwrap_or_default`: an empty vec is a + // wrong-sized field, `SubjectMasks::upload` rejects the whole + // batch on one, and every other layer in the stack then loses + // its mask too. One stale name should cost one layer, not all + // of them. + // + // It is also the placeholder a gradient or a brush gets, so + // the slot indices line up with `active()` whatever mix of + // sources the stack holds. + None => vec![-1.0; pw * ph], + }; + fields.push(field); + } + + if fields.is_empty() { + self.subjects = None; + self.subject_key = key; + return; + } + + let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect(); + self.subjects = dr_gpu::SubjectMasks::upload(ctx, &refs, pw as u32, ph as u32) + .inspect_err(|e| log::warn!("could not upload the subject fields: {e}")) + .ok(); + self.subject_key = key; + } + + pub(super) fn rasterise_masks(&mut self) -> bool { + // TRACES: FR-DEV-19c + // A stack that changes no pixel is normally not worth a pass — except + // when one of its layers is being looked at, which is exactly the + // state a fresh selection is in. Asking `rendered_count` rather than + // `is_neutral` is what makes "click a category, see its mask" work at + // all: before it, the array was never rasterised, the slice the reveal + // samples held whatever was last in it, and the answer was a blank + // photograph. + let reveal = self.reveal(); + if self.graph.masks().rendered_count(reveal.as_ref()) == 0 { + return false; + } + // **Source space, at the segmentation's proxy size** — not the + // viewport's. The generated shader samples this after the framing map, + // so a mask drawn here stays on the photograph through a zoom, a pan + // and a crop. Rasterising at viewport size, as this first did, pinned + // the mask to the screen instead: zooming slid the picture underneath + // one that stayed put. + // + // It also means the array does not reallocate when the window + // resizes, and does not need redrawing when the view moves. + let (pw, ph) = self.mask_raster_size(); + let subjects = self.subjects.as_ref(); + // TRACES: FR-DEV-10 + // A refcount, taken before the rasteriser is borrowed mutably. A range + // layer measures the photograph itself, so the pass needs the source + // as well as the stack — and it is the same texture every other pass + // reads, not a copy made for masking. + let source = self.demosaiced.clone(); + + // **Built here, not in `segment`.** A gradient needs no segmentation — + // a graduated filter over a sky never had to know what a sky is — but + // the rasteriser was only ever constructed on the way out of one, so + // adding a gradient to a photograph nobody had segmented produced an + // array that was never rasterised and a layer that drew nothing at + // all. Silently: the generated shader still emits the layer's block + // and the empty placeholder multiplies it by zero, which is the same + // failure exports and thumbnails had. + // + // The shader compile this costs is paid once, on the first frame after + // the first mask is added — a button press, not a frame anyone is + // dragging through. `is_neutral` above is what keeps it off the path + // of every photograph that has no local adjustment at all. + if self.masks.is_none() { + let ctx = self.ctx.clone(); + self.masks = dr_gpu::MaskPass::new(&ctx) + .inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}")) + .ok(); + } + let Some(pass) = self.masks.as_mut() else { + return false; + }; + // No label field: region masks were the watershed's, and nothing + // produces one any more. A stored layer that still names regions is + // skipped by the rasteriser rather than drawn wrong. + pass.render_revealing( + self.graph.masks(), + None, + subjects, + Some(source.as_ref()), + pw, + ph, + reveal.as_ref(), + ) + .inspect_err(|e| log::warn!("mask rasterisation failed: {e}")) + .is_ok() + } + + /// The size the mask array is rasterised at, in source space. + /// + /// **A property of the photograph, not of the segmentation.** The two come + /// out the same because both are the source scaled to `SEGMENT_PROXY_EDGE`, + /// and they have to: a subject layer's distance field is built at the + /// segmentation's proxy and sampled against this array, so the two sizes + /// agreeing is a requirement rather than a coincidence. Reading the size + /// *off* the segmentation is what made it look like a dependency, and made + /// a gradient — which indexes nothing — wait for a model to run. + /// + /// The aspect must be the source's either way. A gradient's geometry is + /// measured against the frame's own proportions, so a square array over a + /// 3:2 photograph would stretch every circle it drew. + pub(super) fn mask_raster_size(&self) -> (u32, u32) { + if let Some(seg) = self.segmentation.as_ref() { + let (pw, ph) = seg.proxy_size(); + return (pw as u32, ph as u32); + } + let (sw, sh) = self.demosaiced.size(); + let scale = (SEGMENT_PROXY_EDGE as f32 / sw.max(sh).max(1) as f32).min(1.0); + ( + ((sw as f32 * scale) as u32).max(1), + ((sh as f32 * scale) as u32).max(1), + ) + } + + /// The selected layer's mask rule, for a caller that has to remember what + /// a gesture started from. + pub fn active_mask_source(&self) -> Option { + self.active_layer().map(|l| l.base().source.clone()) + } + + /// Select exactly one layer for editing, or `None` to return the panel to + /// the global chain. Replaces whatever was selected before, including a + /// multi-selection — the ordinary, unmodified click. + /// Selecting a layer points the tools at its base again. + /// + /// Without this, selecting a two-part mask after a three-part one would + /// leave the brush aimed at a part that is not there — or, worse, at a + /// different part than the one highlighted in the panel. + pub fn set_active_mask(&mut self, id: Option<&str>) { + self.active_part = 0; + self.active_masks = id + .filter(|id| self.graph.masks().get(id).is_some()) + .map(|id| vec![id.to_string()]) + .unwrap_or_default(); + } + + /// Add or remove one layer from the selection, keeping the rest — the + /// modifier-click that builds a multi-selection. + /// + /// A layer id the graph no longer has is dropped rather than toggled in: + /// the row that offered it is stale by the time the click lands, and + /// selecting a ghost would make every subsequent batched edit silently + /// skip it (`active_layers_mut` filters by membership, not existence). + pub fn toggle_active_mask(&mut self, id: &str) { + if self.graph.masks().get(id).is_none() { + return; + } + match self.active_masks.iter().position(|a| a == id) { + Some(i) => { + self.active_masks.remove(i); + } + None => self.active_masks.push(id.to_string()), + } + } + + /// Mask the object under a normalised image point. + /// + /// Clicking the photograph is how a local adjustment begins, so this + /// creates the layer — requiring "add layer" first would be a step with no + /// decision in it. + /// + /// Returns the layer that now holds the selection. + pub fn select_region_at(&mut self, x: f32, y: f32) -> Option { + let index = self.segmentation.as_ref()?.instance_at(x, y)?; + self.add_subject_mask(index) + } + + /// Add a layer covering one photographic category. + /// + /// The counterpart to [`Self::add_subject_mask`], and it takes a *name* + /// rather than an index for the reason `MaskSource::Category` stores one: + /// the descriptor grouping ADE20K's classes is editable, so an index would + /// silently repoint every stored layer the first time a category was + /// added to it. + /// + /// The layer is named after the category, because "sky" is a better name + /// for a layer than "Mask 3" and the user can rename it anyway. + pub fn add_category_mask(&mut self, name: &str) -> Option { + use dr_pipeline::mask::MaskSource; + + let seg = self.segmentation.as_ref()?; + let signature = seg.signature(); + // Refuse a category this run did not produce rather than creating a + // layer that renders empty: an empty mask looks like a broken + // adjustment, where a button that does nothing at least says so. + seg.category_mask(name)?; + + let id = self.graph.masks().next_id(); + let mut layer = MaskLayer::new( + id.clone(), + MaskSource::Category { + signature, + name: name.to_string(), + }, + ); + layer.name = name.to_string(); + // Refined from the start, by as much as this photograph will bear. + // + // A category's edges are twenty proxy pixels wide before this runs, so + // the unrefined mask is the wrong default for the common case — a + // photographer adding a sky mask wants the sky, not the sky plus every + // chimney in it. Zero is still one drag away, and it is exactly the + // model's own weighting when they get there. + // + // **Asked of the frame rather than taken from a constant**, and that + // is the correction rather than a refinement of the idea. The constant + // was `STRICTNESS_DEFAULT`, fitted on a synthetic sky; on real + // photographs it removes three quarters to all of `architecture`, + // `ground` and `vegetation`, so clicking a category produced an empty + // mask — the failure that reads as the feature not working at all, + // because the adjustment moves and no pixel changes. The measurements + // are on `dr_segment::Refinement::gentle`, which is what picks the + // number now. + // + // Set here rather than in `MaskLayer::new` because the number belongs + // to `dr_segment` and `dr-pipeline` does not depend on it — see + // `dr_pipeline::mask::MAX_REFINE`. + layer.base_mut().refine = self + .segmentation + .as_ref() + .map_or(dr_segment::STRICTNESS_OFF, |seg| { + seg.category_default_refine(name) + }); + if !self.graph.masks_mut().push(layer) { + return None; + } + self.active_masks = vec![id.clone()]; + self.show_new_mask(&id); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); + Some(id) + } + + /// What the scene model found in this frame, largest category first. + /// + /// Empty when no scene model was available, which is an ordinary state — + /// see `segmentation::scene_categories`. + pub fn categories(&self) -> &[segmentation::CategorySummary] { + self.segmentation.as_ref().map_or(&[], |s| s.categories()) + } + + /// Add a layer selecting one detected subject. + /// + /// The instance's own coverage is the mask, rather than the watershed + /// regions it overlaps. Snapping to regions was the original design and + /// it is not currently worth doing: the hierarchy those ids index into + /// collapses on a photograph (docs/dev/segmentation.md §15), so snapping + /// would trade the model's approximately-right outline for a + /// confidently-wrong one. + pub fn add_subject_mask(&mut self, index: usize) -> Option { + let seg = self.segmentation.as_ref()?; + let instance = seg.instances().get(index)?; + let signature = seg.signature(); + let name = instance.class_name.to_string(); + let score = instance.score; + + let id = self.graph.masks().next_id(); + let mut layer = MaskLayer::new( + id.clone(), + MaskSource::Subject { + signature, + index: index as u32, + class: name.clone(), + score, + }, + ); + layer.name = name; + if !self.graph.masks_mut().push(layer) { + return None; + } + self.active_masks = vec![id.clone()]; + self.show_new_mask(&id); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); + Some(id) + } + + /// Add a gradient layer, which needs no segmentation. + pub fn add_gradient_mask(&mut self, radial: bool) -> Option { + let id = self.graph.masks().next_id(); + let source = if radial { + MaskSource::Radial { + centre: (0.5, 0.5), + radii: (0.35, 0.35), + angle: 0.0, + feather: 0.5, + } + } else { + MaskSource::Linear { + centre: (0.5, 0.5), + angle: std::f32::consts::FRAC_PI_2, + width: 0.3, + } + }; + if !self + .graph + .masks_mut() + .push(MaskLayer::new(id.clone(), source)) + { + return None; + } + self.active_masks = vec![id.clone()]; + self.show_new_mask(&id); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); + Some(id) + } + + /// TRACES: FR-DEV-19b + /// Add a mask that is nothing but hand-painted, and select it. + /// + /// **The one route to a brush that starts from nothing.** Everything else + /// in this file makes a layer out of a selection — a gradient, a band, a + /// subject, a category — and painting was reachable only by making one of + /// those first and then joining a painted part to it. So the answer to + /// "brush a correction onto this corner of the sky" was "add a radial + /// gradient you do not want, then paint into it", which is not an answer. + /// + /// The layer covers nothing until a stroke lands in it, which is exactly + /// what [`dr_pipeline::mask::MaskPart::covers`] is about: it is not active, + /// it costs no slice, and an invert on it would not take the adjustment + /// global. What the panel shows meanwhile is a row with the tools armed + /// over it — see `masks_ui`, which arms them. + pub fn add_brush_mask(&mut self) -> Option { + let id = self.graph.masks().next_id(); + if !self + .graph + .masks_mut() + .push(MaskLayer::new(id.clone(), MaskSource::brush())) + { + return None; + } + self.active_masks = vec![id.clone()]; + self.active_part = 0; + self.show_new_mask(&id); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); + Some(id) + } + + /// TRACES: FR-DEV-10 + /// Add a mask that selects by a range of the photograph's own values. + /// + /// Needs no segmentation, like a gradient, and for a stronger reason: a + /// band is a question about the picture rather than about what is in it. + /// `chromatic` picks the colour range over the brightness one. + pub fn add_range_mask(&mut self, chromatic: bool) -> Option { + let id = self.graph.masks().next_id(); + let source = if chromatic { + MaskSource::skin_tones() + } else { + MaskSource::highlights() + }; + if !self + .graph + .masks_mut() + .push(MaskLayer::new(id.clone(), source)) + { + return None; + } + self.active_masks = vec![id.clone()]; + self.show_new_mask(&id); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); + Some(id) + } + + /// TRACES: FR-DEV-10 + /// Move a range layer's band — its two bounds and the fade at each edge. + /// + /// All three at once, because they are one control: dragging the lower + /// bound past the upper swaps them (see `MaskSource::luminance_range`), + /// and a setter per field would have to decide that question three times + /// with only a third of the answer each time. + /// + /// A no-op on a layer that is not a range, rather than a panic: the panel + /// asks first, and a callback that arrives against a layer the user has + /// since replaced is ordinary rather than a fault. + pub fn set_mask_band(&mut self, id: &str, lo: f32, hi: f32, softness: f32) { + let Some(layer) = self.part_of_mut(id) else { + return; + }; + // Built into a temporary first: the arc is read out of the layer and + // the whole source is written back over it, and the two cannot be the + // same statement. + let rebuilt = match &layer.source { + MaskSource::Luminance { .. } => MaskSource::luminance_range(lo, hi, softness), + MaskSource::Colour { hue, hue_width, .. } => { + MaskSource::colour_range(*hue, *hue_width, lo, hi, softness) + } + _ => return, + }; + layer.source = rebuilt; + // `Control`, not `Action`: a band is dragged, and a drag is one + // decision however many values it passes through. + self.history + .record(&self.graph, Edit::Control(labels::step::MASK_RANGE)); + } + + /// TRACES: FR-DEV-10 + /// Move a colour range's arc — its centre hue and its half-width. + pub fn set_mask_hue(&mut self, id: &str, hue: f32, width: f32) { + let Some(layer) = self.part_of_mut(id) else { + return; + }; + // A temporary, for the reason `set_mask_band` gives. + let rebuilt = match &layer.source { + MaskSource::Colour { + chroma_lo, + chroma_hi, + softness, + .. + } => MaskSource::colour_range(hue, width, *chroma_lo, *chroma_hi, *softness), + // Only a colour range has an arc. A brightness one reaching here + // is a stale callback against a layer the user has replaced, + // which is ordinary rather than a fault. + _ => return, + }; + layer.source = rebuilt; + self.history + .record(&self.graph, Edit::Control(labels::step::MASK_RANGE)); + } + + /// TRACES: FR-DEV-10 + /// Whether the band controls apply to this layer. + pub fn mask_is_ranged(&self, id: &str) -> bool { + self.part_of(id).is_some_and(|p| p.source.is_range()) + } + + /// TRACES: FR-DEV-10 + /// Whether this layer's band is over colour rather than over brightness. + pub fn mask_is_chromatic(&self, id: &str) -> bool { + self.part_of(id) + .is_some_and(|p| matches!(p.source, MaskSource::Colour { .. })) + } + + /// TRACES: FR-DEV-10 + /// This layer's band: lower bound, upper bound, softness. + /// + /// Tone positions for a luminance layer and chroma for a colour one — + /// the same two questions asked of different measurements, which is why + /// one panel control drives both. Zeroes for a layer that has no band, + /// which the panel never draws. + pub fn mask_band(&self, id: &str) -> (f32, f32, f32) { + match self.part_of(id).map(|p| &p.source) { + Some(MaskSource::Luminance { lo, hi, softness }) => (*lo, *hi, *softness), + Some(MaskSource::Colour { + chroma_lo, + chroma_hi, + softness, + .. + }) => (*chroma_lo, *chroma_hi, *softness), + _ => (0.0, 0.0, 0.0), + } + } + + /// TRACES: FR-DEV-10 + /// A colour range's arc: centre hue and half-width, both in turns. + pub fn mask_hue(&self, id: &str) -> (f32, f32) { + match self.part_of(id).map(|p| &p.source) { + Some(MaskSource::Colour { hue, hue_width, .. }) => (*hue, *hue_width), + _ => (0.0, 0.0), + } + } + + pub fn remove_mask(&mut self, id: &str) { + if self.graph.masks_mut().remove(id).is_some() { + self.active_masks.retain(|a| a != id); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_REMOVED)); + } + } + + pub fn set_mask_enabled(&mut self, id: &str, enabled: bool) { + if let Some(layer) = self.graph.masks_mut().get_mut(id) { + layer.enabled = enabled; + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_TOGGLED)); + } + } + + pub fn set_mask_invert(&mut self, id: &str, invert: bool) { + if let Some(layer) = self.graph.masks_mut().get_mut(id) { + layer.invert = invert; + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_INVERTED)); + } + } + + /// Edge transition half-width, in fractions of the shorter edge. + pub fn set_mask_feather(&mut self, id: &str, feather: f32) { + if let Some(part) = self.part_of_mut(id) { + part.feather = feather.clamp(0.0, 1.0); + self.history + .record(&self.graph, Edit::Control(labels::step::MASK_FEATHER)); + } + } + + /// How strictly this layer's category is cut back to the pixels whose + /// colour agrees with it. + /// + /// Unlike the feather, this changes the mask's *shape*, so the distance + /// field has to be rebuilt — the same class of cost as a close or an open + /// (`dr_segment::Morphology::needs_recompute`), and the reason it is in + /// `subject_signature`. It still runs no model: the evidence was fitted + /// during segmentation and this is a smoothstep over it. + pub fn set_mask_refine(&mut self, id: &str, refine: f32) { + if let Some(part) = self.part_of_mut(id) { + part.refine = refine.clamp(0.0, dr_pipeline::mask::MAX_REFINE); + self.history + .record(&self.graph, Edit::Control(labels::step::MASK_REFINE)); + } + } + + pub fn set_mask_falloff(&mut self, id: &str, index: usize) { + use dr_pipeline::mask::Falloff; + let Some(&falloff) = Falloff::ALL.get(index) else { + return; + }; + if let Some(part) = self.part_of_mut(id) { + part.falloff = falloff; + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_FALLOFF)); + } + } + + pub fn set_mask_morphology(&mut self, id: &str, index: usize) { + use dr_pipeline::mask::Morphology; + let Some(&morphology) = Morphology::ALL.get(index) else { + return; + }; + if let Some(part) = self.part_of_mut(id) { + part.morphology = morphology; + // Picking an operation with no amount set would appear to do + // nothing, and the user would reasonably conclude it is broken. + if morphology != Morphology::None && part.morph_radius <= 0.0 { + part.morph_radius = 0.006; + } + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_MORPHOLOGY)); + } + } + + pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) { + if let Some(part) = self.part_of_mut(id) { + part.morph_radius = radius.clamp(0.0, 1.0); + self.history + .record(&self.graph, Edit::Control(labels::step::MASK_MORPH)); + } + } + + /// Which falloff a layer uses, as an index into `Falloff::ALL`. + pub fn mask_falloff(&self, id: &str) -> usize { + use dr_pipeline::mask::Falloff; + self.part_of(id).map_or(0, |p| { + Falloff::ALL + .iter() + .position(|&f| f == p.falloff) + .unwrap_or(0) + }) + } + + pub fn mask_morphology(&self, id: &str) -> usize { + use dr_pipeline::mask::Morphology; + self.part_of(id).map_or(0, |p| { + Morphology::ALL + .iter() + .position(|&m| m == p.morphology) + .unwrap_or(0) + }) + } + + pub fn mask_feather(&self, id: &str) -> f32 { + self.part_of(id).map_or(0.0, |p| p.feather) + } + + pub fn mask_morph_radius(&self, id: &str) -> f32 { + self.part_of(id).map_or(0.0, |p| p.morph_radius) + } + + pub fn mask_refine(&self, id: &str) -> f32 { + self.part_of(id).map_or(0.0, |p| p.refine) + } + + /// Whether this layer has a refinement to act on. + /// + /// Two conditions, and both are needed. The source must be a category — + /// nothing else has a colour model fitted for it — and the segmentation + /// must actually have fitted one, which it cannot for a category that is + /// everywhere thinner than the model's own resolution or that fills the + /// whole frame. + /// + /// Asked by the panel before it draws the slider, because a control that + /// moves and does nothing is worse than an absent one. + pub fn mask_is_refinable(&self, id: &str) -> bool { + use dr_pipeline::mask::MaskSource; + let Some(layer) = self.graph.masks().get(id) else { + return false; + }; + let index = self.shaped_part(id); + let Some(part) = layer.part(index) else { + return false; + }; + let MaskSource::Category { name, .. } = &part.source else { + return false; + }; + self.segmentation + .as_ref() + .is_some_and(|seg| seg.category_is_refinable(name)) + } + + /// Whether the edge controls apply to this layer. + /// + /// Only sources that go through the distance field. A gradient carries its + /// own falloff in its geometry, so offering a second one would be two + /// controls fighting over the same edge. + /// + /// A range (FR-DEV-10) is excluded on stronger grounds than the gradient. + /// Feather, falloff and morphology are every one of them a function of the + /// *signed distance from a boundary*, and a range mask has no boundary: it + /// is a weighting over the photograph's values, soft everywhere the values + /// are, sharp everywhere they are. There is no outline to grow, shrink or + /// cross. Its edge is the softness of its own band, which is in the band's + /// units rather than in pixels — see `MaskSource::Luminance`. + pub fn mask_is_shapeable(&self, id: &str) -> bool { + use dr_pipeline::mask::MaskSource; + self.part_of(id).is_some_and(|p| { + matches!( + p.source, + MaskSource::Subject { .. } + | MaskSource::Category { .. } + | MaskSource::Regions { .. } + ) + }) + } + + pub fn set_mask_opacity(&mut self, id: &str, opacity: f32) { + if let Some(layer) = self.graph.masks_mut().get_mut(id) { + layer.opacity = opacity.clamp(0.0, 1.0); + // `Op` rather than `Discrete`: opacity is dragged, and a drag is + // one decision however many values it passes through. `Discrete` + // would put every intermediate position on the undo stack. + self.history + .record(&self.graph, Edit::Control(labels::step::MASK_OPACITY)); + } + } + + /// Whether a layer's region ids belong to a segmentation other than the + /// one currently loaded — a mask restored from a sidecar written under + /// different tuning. + pub fn mask_is_stale(&self, id: &str) -> bool { + let Some(layer) = self.graph.masks().get(id) else { + return false; + }; + match self.segmentation.as_ref() { + Some(seg) => layer.is_stale(seg.signature()), + // Nothing loaded to compare against, and nothing to report: the + // layer renders from the raster its sidecar stored (see + // `layer_coverage`). It was already not *stale* before that — the + // word invites the user to recompute a selection that is fine — + // and now it is not unrenderable either. + None => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::develop::test_support::*; + + /// TRACES: FR-DEV-3 + /// A gradient needs no segmentation, and until now it silently got no mask. + /// + /// The rasteriser was built on the way out of `segment`, so a gradient + /// added to a photograph nobody had segmented had nothing to draw it — and + /// the failure was invisible from every side. The generated shader still + /// emits the layer's block, the empty placeholder multiplies it by zero, + /// and the result is a well-formed frame with the local adjustment simply + /// absent. No error, no warning, and nothing on screen to tell it apart + /// from a mask the user had placed badly. + /// + /// A graduated filter over a sky never had to know what a sky is, so the + /// dependency was wrong as well as silent. + #[test] + fn a_gradient_renders_on_a_photograph_nobody_has_segmented() { + let Some(ctx) = headless() else { return }; + + // Mid grey, so a brightening layer is unambiguous either way. + let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) + .expect("session"); + assert!( + !session.has_segmentation(), + "the point of the test is that there is none" + ); + + let before = read_back(&ctx, &session.render(64, 64).expect("render")); + + // A radial over the middle, brightened hard. Addressed by index, so + // this names no operation (FR-DEV-3a). + session.add_gradient_mask(true).expect("a radial"); + let row = session.rows()[0].clone(); + session.set_param(row.op_index, row.param_index, row.maximum); + + let after = read_back(&ctx, &session.render(64, 64).expect("render")); + + let centre = |px: &[u8]| px[((32 * 64 + 32) * 4) as usize]; + assert!( + centre(&after) > centre(&before) + 20, + "the middle of the frame must brighten: {} against {}", + centre(&after), + centre(&before) + ); + // And only the middle: a mask that failed to rasterise the other way — + // covering everything — would pass the assertion above. + let corner = |px: &[u8]| px[0]; + assert_eq!( + corner(&after), + corner(&before), + "the corner is outside the radial and must not move" + ); + } + + // ---------------------------------------------------------------------- + // Stored coverage (dr_pipeline::coverage) + // ---------------------------------------------------------------------- + // + // A subject or category layer is stored in the sidecar as identity — which + // run, which instance, which category — and identity resolves to pixels + // only while that run is in memory. So reopening an edited photograph + // dropped every model-backed local adjustment, and a batch export, which + // never runs a model at all, could not have them at any point. Both + // failures were silent: the shader still emitted the layer's block, the + // placeholder multiplied it by zero, and the result was a well-formed + // frame with the adjustment simply absent. + + /// A stored edit holding one subject layer over the left half of the + /// frame, as a session that *had* run the model would have written it. + /// + /// `stored` is the whole variable: with the raster, this is a sidecar + /// written by a build that persists coverage; without it, one written + /// before that existed. Everything else about the two is identical, which + /// is what makes the pair of tests below a measurement rather than an + /// assertion about two different edits. + fn version_with_a_subject(proxy: (u32, u32), stored: bool) -> dr_pipeline::Version { + use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS}; + + let (pw, ph) = (proxy.0 as usize, proxy.1 as usize); + let mut values = vec![0u8; pw * ph]; + for y in 0..ph { + for x in 0..pw / 2 { + values[y * pw + x] = 255; + } + } + + let mut layer = MaskLayer::new( + "m1", + MaskSource::Subject { + signature: 0xfeed, + index: 0, + class: "dog".into(), + score: 0.9, + }, + ); + layer.set_param("exposure", ParamId("exposure"), 2.0); + if stored { + layer.base_mut().coverage = Some(Arc::new( + Coverage::encode(&values, pw, ph, RENDERED_LEVELS).expect("a half frame encodes"), + )); + } + + let mut graph = EditGraph::default_chain(); + graph.masks_mut().push(layer); + dr_pipeline::Version::from_graph("default", "Default", &graph) + } + + /// TRACES: FR-DEV-3 | FR-CAT-8 + /// Reopening an edited photograph renders its subject mask, with no model. + /// + /// This is the failure the stored raster exists for. `apply_version` is + /// exactly what opening a photograph from the library does with the + /// sidecar it fetched, and until the coverage went into the file the layer + /// resolved to nothing every time. + #[test] + fn a_stored_subject_mask_renders_with_no_model_run() { + let Some(ctx) = headless() else { return }; + let (mut session, before) = grey_session(&ctx); + + let proxy = session.mask_raster_size(); + session.apply_version(&version_with_a_subject(proxy, true)); + let after = read_back(&ctx, &session.render(64, 64).expect("render")); + + let at = |px: &[u8], x: usize, y: usize| px[(y * 64 + x) * 4]; + assert!( + at(&after, 16, 32) > at(&before, 16, 32) + 20, + "the covered half must brighten: {} against {}", + at(&after, 16, 32), + at(&before, 16, 32) + ); + // And only that half. A mask that failed the other way — covering + // everything — would pass the assertion above and is the louder bug. + assert_eq!( + at(&after, 56, 32), + at(&before, 56, 32), + "the uncovered half must not move" + ); + } + + /// The control for the test above: the same edit with the raster left out + /// is the behaviour every build had before this, which is no mask at all. + /// + /// Worth pinning down in both directions. It is what a sidecar written by + /// an older build looks like, and reading one must go on being harmless — + /// the layer needs the model run, exactly as it always did, rather than + /// rendering as an empty mask or as the whole frame. + #[test] + fn a_subject_mask_with_no_stored_coverage_still_needs_the_model() { + let Some(ctx) = headless() else { return }; + let (mut session, before) = grey_session(&ctx); + + let proxy = session.mask_raster_size(); + session.apply_version(&version_with_a_subject(proxy, false)); + let after = read_back(&ctx, &session.render(64, 64).expect("render")); + + assert_eq!( + after, before, + "with no coverage the layer contributes nothing" + ); + } + + /// TRACES: FR-EXP-9 | FR-CAT-8 + /// A batch export renders the stored mask too. + /// + /// `export::render_from_library` fetches the RAW and the sidecar, opens a + /// session, applies the version and calls `render_for_export` — and it + /// runs no model on the way, so before the coverage was stored a batch + /// wrote out files with the photographer's local adjustments missing, over + /// a log warning nobody was reading. These two lines are that path with + /// the fetch taken out. + #[test] + fn an_export_renders_a_stored_subject_mask() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) + .expect("session"); + + let plain = session + .render_for_export(dr_types::ColourSpace::Srgb) + .expect("export"); + + let proxy = session.mask_raster_size(); + session.apply_version(&version_with_a_subject(proxy, true)); + let masked = session + .render_for_export(dr_types::ColourSpace::Srgb) + .expect("export"); + + let at = |f: &dr_export::Frame, x: usize, y: usize| f.rgba[(y * 64 + x) * 4]; + assert!( + at(&masked, 16, 32) > at(&plain, 16, 32) + 20, + "the exported file must carry the local adjustment: {} against {}", + at(&masked, 16, 32), + at(&plain, 16, 32) + ); + assert_eq!( + at(&masked, 56, 32), + at(&plain, 56, 32), + "and only where the mask covers" + ); + } + + /// What a session hands the sidecar writer when no model has run. + /// + /// Untouched, and that is the whole of it: a photograph opened, looked at + /// and closed must not have the coverage its own sidecar gave it stripped + /// out on the way past. Saving is automatic, so a stack that lost the + /// raster here would lose it on disk within seconds of being opened. + #[test] + fn saving_without_a_model_keeps_the_coverage_that_was_loaded() { + let Some(ctx) = headless() else { return }; + let (mut session, _) = grey_session(&ctx); + + let proxy = session.mask_raster_size(); + session.apply_version(&version_with_a_subject(proxy, true)); + let stored = session.masks_for_storage(); + + assert_eq!(stored.len(), 1); + assert!( + stored.layers()[0].base().coverage.is_some(), + "the raster the sidecar gave us must go back to the sidecar" + ); + } + + #[test] + fn a_stored_mask_renders_exactly_what_the_model_rendered() { + let Some(ctx) = headless() else { return }; + + let mut live = session_with_a_left_half_subject(&ctx); + let id = live.add_subject_mask(0).expect("a subject layer"); + // TRACES: FR-DEV-19c + // Making a mask opens its eye (`show_new_mask`), and this test is + // about the pixels the *edit* produces. Closed here rather than left + // open, and the asymmetry is the point rather than an inconvenience: + // the reveal is how somebody is looking at a photograph, so a session + // that has just made a layer legitimately draws a frame that a session + // which read the same layer out of a file does not. Both of those are + // correct, and only one of them is what a stored raster has to + // reproduce. + live.set_mask_shown(&id, false); + live.graph + .masks_mut() + .get_mut(&id) + .expect("the layer") + .set_param("exposure", ParamId("exposure"), 2.0); + let with_model = read_back(&ctx, &live.render(64, 64).expect("render")); + + // Through the sidecar, the way `presets::save_open_edit` does it: the + // graph's own parameters, with the stack that carries the coverage + // substituted for the one the graph is holding. + let mut version = dr_pipeline::Version::from_graph("default", "Default", &live.graph); + version.masks = live.masks_for_storage(); + let mut sidecar = dr_pipeline::Sidecar::new(); + sidecar.put(version); + let text = sidecar.to_text(); + let parsed = dr_pipeline::Sidecar::parse(&text).expect("reparse"); + + let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); + let mut reopened = + DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) + .expect("session"); + assert!( + !reopened.has_segmentation(), + "the point: nothing has run a model in this session" + ); + reopened.apply_version(parsed.default_version().expect("a version")); + // TRACES: FR-DEV-19c + // And a sidecar carries no viewing state: a photograph reopened is not + // reopened with its masks tinted red. Checked here because this is the + // one test that puts an edit through a file and renders both ends, so + // it is where the property would first go wrong. + assert!( + !reopened.any_mask_shown(), + "restoring an edit must not open an eye" + ); + let from_the_file = read_back(&ctx, &reopened.render(64, 64).expect("render")); + + assert_eq!( + from_the_file, with_model, + "the reopened frame must be the frame the model produced" + ); + // And that both are actually a mask rather than both being nothing. + let at = |px: &[u8], x: usize| px[(32 * 64 + x) * 4]; + assert!( + at(&with_model, 16) > at(&with_model, 56) + 20, + "the premise: the live render really is masked ({} against {})", + at(&with_model, 16), + at(&with_model, 56) + ); + } + + /// And what a session hands over when a model *has* run: the coverage + /// folded in, at the proxy the distance field is measured in. + /// + /// The encoding happens here rather than where the field is built because + /// that runs on a drag — see `masks_for_storage`. + #[test] + fn saving_after_a_model_run_folds_the_coverage_in() { + let Some(ctx) = headless() else { return }; + let mut session = session_with_a_left_half_subject(&ctx); + let id = session.add_subject_mask(0).expect("a subject layer"); + + let stored = session.masks_for_storage(); + let coverage = stored + .get(&id) + .expect("the layer is in the stack") + .base() + .coverage + .as_ref() + .expect("a subject the model found has coverage to store"); + + let (pw, ph) = session.mask_raster_size(); + assert_eq!( + (coverage.width(), coverage.height()), + (pw as usize, ph as usize) + ); + let decoded = coverage.decode(); + assert_eq!(decoded[32 * pw as usize + 8], 255, "inside the subject"); + assert_eq!(decoded[32 * pw as usize + 56], 0, "outside it"); + assert!( + coverage.encoded_len() < 512, + "a half-frame mask is a handful of runs, not {} bytes", + coverage.encoded_len() + ); + } + + /// A layer whose coverage came from the file must still take the model's + /// once a model runs — the live answer is the only one that responds to + /// the refine control, and a run in this sitting is newer than a file. + #[test] + fn a_model_run_takes_precedence_over_what_was_stored() { + let Some(ctx) = headless() else { return }; + let mut session = session_with_a_left_half_subject(&ctx); + + // Stored coverage saying the *right* half, against a segmentation + // saying the left. + let (pw, ph) = session.mask_raster_size(); + let (pw, ph) = (pw as usize, ph as usize); + let mut mirrored = vec![0u8; pw * ph]; + for y in 0..ph { + for x in pw / 2..pw { + mirrored[y * pw + x] = 255; + } + } + + let id = session.add_subject_mask(0).expect("a subject layer"); + { + let layer = session.graph.masks_mut().get_mut(&id).expect("the layer"); + layer.set_param("exposure", ParamId("exposure"), 2.0); + layer.base_mut().coverage = Some(Arc::new( + dr_pipeline::Coverage::encode( + &mirrored, + pw, + ph, + dr_pipeline::coverage::RENDERED_LEVELS, + ) + .expect("encode"), + )); + } + + let frame = read_back(&ctx, &session.render(64, 64).expect("render")); + let at = |x: usize| frame[(32 * 64 + x) * 4]; + assert!( + at(16) > at(56) + 20, + "the model's left half must win over the file's right half: \ + {} against {}", + at(16), + at(56) + ); + } + + /// TRACES: FR-DEV-3 + /// The mask array and the segmentation proxy are the same size on purpose. + /// + /// They have to be: a subject layer's distance field is built at the + /// segmentation's proxy resolution and sampled against the array, so if the + /// two ever diverged a subject mask would be drawn at the wrong scale — + /// a mask that is confidently in the wrong place, which is worse than none. + /// + /// It used to hold because the array's size was *read off* the + /// segmentation, which also made a gradient wait for a model it does not + /// use. Deriving both from the photograph keeps the agreement and drops the + /// dependency, and this is what stops the agreement being an accident. + #[test] + fn the_mask_array_is_the_size_the_segmentation_will_use() { + let Some(ctx) = headless() else { return }; + + let rgba: Vec = (0..100 * 100) + .flat_map(|_| [128u8, 128, 128, 255]) + .collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL) + .expect("session"); + + let before = session.mask_raster_size(); + if session + .segment(&crate::segmentation::Options::default()) + .is_err() + { + eprintln!("no model; skipping"); + return; + } + assert_eq!( + before, + session.mask_raster_size(), + "a mask rasterised before the model ran must not move when it does" + ); + } +} diff --git a/ui/dr-ui/src/develop/masks.rs b/ui/dr-ui/src/develop/masks.rs new file mode 100644 index 0000000..927fee8 --- /dev/null +++ b/ui/dr-ui/src/develop/masks.rs @@ -0,0 +1,920 @@ +//! Looking at masks: view style, the segmentation overlay, the layer list, +//! and editing a mask by hand with the brush. +#[cfg(test)] +use dr_gpu::GpuContext; +use dr_pipeline::mask::MaskSource; + +use crate::labels; +use dr_pipeline::Edit; + +use super::session::DevelopSession; + +/// TRACES: FR-DEV-19c +/// How one layer's mask is shown on the canvas. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct MaskView { + shown: bool, + /// Index into [`MASK_COLOURS`]. + colour: usize, +} + +/// TRACES: FR-DEV-19c +/// The colours a mask may be shown in, in linear sRGB. +/// +/// Six, chosen to be told apart at half strength over a photograph rather +/// than to be pretty: red and green and blue at the corners, and the three +/// between them. Exposed so the panel draws its swatches from the same table +/// the shader is handed, and a seventh colour is one line here and nowhere +/// else. +pub const MASK_COLOURS: [[f32; 3]; 6] = [ + [0.85, 0.10, 0.15], + [0.15, 0.80, 0.25], + [0.20, 0.45, 1.00], + [0.95, 0.80, 0.10], + [0.90, 0.20, 0.85], + [0.15, 0.85, 0.90], +]; + +impl DevelopSession { + // ---------------------------------------------------------------------- + // Seeing the mask (FR-DEV-19c) + // ---------------------------------------------------------------------- + + /// TRACES: FR-DEV-19c + /// What the canvas should draw over the photograph, if anything. + /// + /// Every layer whose eye is open, in stack order, each in its colour — + /// and `None` when no eye is, so the rasteriser and the composer can + /// take the path they always took. + /// + /// Rebuilt per call rather than kept in step with the stack, because it + /// is a walk over at most eight layers — cheaper than the invalidation a + /// cached copy would need every time a layer is added, removed, renamed + /// or reordered. + pub(crate) fn reveal(&self) -> Option { + use dr_pipeline::mask::{Reveal, RevealedLayer}; + // Only while masking. The eyes are per layer and outlive the mode, + // so a photographer coming back finds the layers they were looking + // at still lit — but a tint is a way of looking at a *mask*, and + // outside Local there is no mask being looked at. Without this the + // sky stayed red through Repair and back in Photo, a mode that had + // been left leaving its overlay behind (ui-navigation.md D-N1). + if !self.show_overlay { + return None; + } + let layers: Vec = self + .graph + .masks() + .layers() + .iter() + .filter_map(|l| { + let view = self.mask_views.get(&l.id).filter(|v| v.shown)?; + Some(RevealedLayer { + layer: l.id.clone(), + colour: MASK_COLOURS[view.colour % MASK_COLOURS.len()], + }) + }) + .collect(); + if layers.is_empty() { + return None; + } + Some(Reveal { + layers, + style: self.reveal_style, + }) + } + + /// How shown masks are drawn, as an index into + /// [`dr_pipeline::mask::RevealStyle::ALL`]. + /// + /// An index because the panel offers it as a strip of chips and an index + /// is what a strip of chips reports. The enum stays the thing that is + /// stored, so a fourth style is a variant and a label rather than a number + /// two files have to agree on. + pub fn mask_view_style(&self) -> usize { + dr_pipeline::mask::RevealStyle::ALL + .iter() + .position(|&a| a == self.reveal_style) + .unwrap_or(0) + } + + /// Choose how shown masks are drawn. + /// + /// Takes no history step and marks nothing dirty: this is how the + /// photograph is being *looked at*, not an edit to it. + pub fn set_mask_view_style(&mut self, style: usize) { + if let Some(&s) = dr_pipeline::mask::RevealStyle::ALL.get(style) { + self.reveal_style = s; + } + } + + /// Whether this layer's mask is drawn over the photograph. + pub fn mask_shown(&self, id: &str) -> bool { + self.mask_views.get(id).is_some_and(|v| v.shown) + } + + /// Open or close one layer's eye. + pub fn set_mask_shown(&mut self, id: &str, shown: bool) { + let colour = self.next_mask_colour(); + self.mask_views + .entry(id.to_string()) + .or_insert(MaskView { + shown: false, + colour, + }) + .shown = shown; + } + + /// Whether any mask at all is being shown. + /// + /// What the region overlay asks before drawing: two overlays that mean + /// different things, on top of each other, is neither. + pub fn any_mask_shown(&self) -> bool { + self.reveal().is_some() + } + + /// Which of [`MASK_COLOURS`] this layer is shown in. + pub fn mask_colour(&self, id: &str) -> usize { + self.mask_views + .get(id) + .map_or(0, |v| v.colour % MASK_COLOURS.len()) + } + + /// Give this layer a colour from [`MASK_COLOURS`]. + /// + /// Choosing a colour is asking to see it: a swatch pressed on a layer + /// whose eye was closed opens the eye, because nothing else the press + /// could mean would change a pixel. + pub fn set_mask_colour(&mut self, id: &str, colour: usize) { + let colour = colour % MASK_COLOURS.len(); + self.mask_views + .entry(id.to_string()) + .and_modify(|v| { + v.colour = colour; + v.shown = true; + }) + .or_insert(MaskView { + shown: true, + colour, + }); + } + + /// The colour the next layer to be shown should take: the first not + /// already in use, or round the palette again once all are. + /// + /// So that two masks made one after the other come up in two colours + /// without anyone having to choose — which is the case that matters, + /// since "how do these two meet" is the question two masks are shown to + /// answer. + fn next_mask_colour(&self) -> usize { + let used: Vec = self.mask_views.values().map(|v| v.colour).collect(); + (0..MASK_COLOURS.len()) + .find(|c| !used.contains(c)) + .unwrap_or(self.mask_views.len() % MASK_COLOURS.len()) + } + + /// TRACES: FR-DEV-19c + /// Show the mask of a layer that has just been made. + /// + /// **Making a mask is asking what it selected**, and for a subject or a + /// category that question has no other answer: the model's outline is not + /// derivable from anything on screen, the layer carries no adjustment yet, + /// and the list it was chosen from says "architecture 23%" and nothing + /// about *which* 23%. So the mask appears with the layer rather than + /// waiting to be asked for a second time — its eye open, in the next + /// colour nothing else is using. + /// + /// Only this layer's eye. Every other layer keeps whatever the + /// photographer set it to, which is the trap `Masking.overlay-hidden` + /// documents: an automatic reveal that undoes a switch somebody turned + /// off is worse than none. + pub(super) fn show_new_mask(&mut self, id: &str) { + let colour = self.next_mask_colour(); + self.mask_views.insert( + id.to_string(), + MaskView { + shown: true, + colour, + }, + ); + } + + // ---------------------------------------------------------------------- + // The region overlay + // ---------------------------------------------------------------------- + + pub fn overlay_enabled(&self) -> bool { + self.show_overlay + } + + pub fn set_overlay(&mut self, on: bool) { + self.show_overlay = on; + } + + /// The part of the overlay the view is currently showing, in overlay + /// pixels: `(x, y, width, height)`. + /// + /// The overlay is a **source-space** picture, and the canvas beside it + /// shows whatever the crop, the zoom and the pan selected out of that same + /// space. Drawn whole, it stays the size of the frame while the photograph + /// moves underneath — which is exactly the fault this exists to fix. + /// + /// Reported as a clip rectangle rather than resampled here: the compositor + /// crops and scales a texture for nothing, where doing it on the CPU would + /// mean rebuilding a megapixel image on every frame of a drag. + /// + /// **Known gap.** A quarter turn or a flip permutes the axes, and a clip + /// rectangle cannot express that — the straightening angle is handled + /// alongside this, but a quarter-turned frame shows the overlay unturned. + /// Fixing it properly means running the overlay through the same shader + /// prologue the image goes through, which is the right answer and a larger + /// one than this. + pub fn overlay_clip(&self) -> (i32, i32, i32, i32) { + let Some(seg) = self.segmentation.as_ref() else { + return (0, 0, 0, 0); + }; + // **Shown pixels, matching `overlay_image`.** The crop and the + // viewport are fractions of the photograph as the user sees it — the + // prologue maps an output pixel through `crop_rect` *before* it + // unturns the frame — so measuring them against the sensor's width + // and height puts the clip on the wrong axis the moment the two + // differ. That is the same confusion as the overlay itself had, one + // layer down, and it is silent for exactly the images where it is + // wrong: a landscape frame has nothing to notice. + let (sw, sh) = seg.proxy_size(); + let (w, h) = self + .graph + .framing() + .effective_orientation() + .oriented_size(sw as u32, sh as u32); + let rect = self.graph.framing().visible_rect(); + + // Rounded outward, so half a pixel of rounding never shows as a strip + // of missing overlay along an edge. + let x = (rect.x * w as f32).floor().max(0.0) as i32; + let y = (rect.y * h as f32).floor().max(0.0) as i32; + let right = ((rect.x + rect.width) * w as f32).ceil().min(w as f32) as i32; + let bottom = ((rect.y + rect.height) * h as f32).ceil().min(h as f32) as i32; + + (x, y, (right - x).max(1), (bottom - y).max(1)) + } + + /// TRACES: FR-DEV-3 + /// A false-coloured picture of what a click can select, for the canvas. + /// + /// Returned as a CPU image rather than a texture, and deliberately: it is + /// regenerated only when the segmentation changes, it is proxy-sized + /// rather than viewport-sized, and the compositor scales and clips it for + /// free. Putting it on the GPU would buy nothing and add a second texture + /// to keep in step with the view. + /// + /// `None` when the overlay is off or nothing has been segmented, so the + /// caller can bind this straight to an image source. + pub fn overlay_image(&self) -> Option { + if !self.show_overlay { + return None; + } + let (rgba, w, h) = self.segmentation.as_ref()?.overlay_rgba(); + + // TRACES: FR-DEV-3h + // **Turned the right way up before it is drawn.** Instance masks live + // in sensor space, because the generated shader samples them after + // the framing map (`uv_src`) — but this is not sampled by that shader. + // It is a flat image handed to the compositor to lay over a + // photograph that *has* been through the framing map, so it has to + // arrive in the same space the photograph is in. + // + // Without this the outlines are drawn in the sensor's orientation over + // an upright picture: on a portrait frame the colour sits nowhere near + // the subject, which reads as the detector having failed rather than + // as the overlay being turned. Nothing announces it, and it is + // invisible on landscape frames, which is most of them. + let (rgba, w, h) = self + .graph + .framing() + .effective_orientation() + .into_shown(&rgba, w, h, 4); + + let buffer = slint::SharedPixelBuffer::::clone_from_slice(&rgba, w, h); + Some(slint::Image::from_rgba8(buffer)) + } + + // ---------------------------------------------------------------------- + // Mask layers + // ---------------------------------------------------------------------- + + /// The layers, as `(id, name, enabled, is_active_selection)`. + pub fn mask_layers(&self) -> Vec<(String, String, bool, bool)> { + self.graph + .masks() + .layers() + .iter() + .map(|l| { + ( + l.id.clone(), + l.display_name().to_string(), + l.enabled, + self.active_masks.iter().any(|a| a == &l.id), + ) + }) + .collect() + } + + /// What kind of mask a layer is — "regions", "linear", "radial". + pub fn mask_kind(&self, id: &str) -> &'static str { + self.part_of(id).map_or("", |p| p.source.kind()) + } + + pub fn mask_inverted(&self, id: &str) -> bool { + self.graph.masks().get(id).is_some_and(|l| l.invert) + } + + pub fn mask_opacity(&self, id: &str) -> f32 { + self.graph.masks().get(id).map_or(1.0, |l| l.opacity) + } + + /// Whether a layer has any adjustment on it yet. + /// + /// Distinct from `is_active`, which also asks whether the layer is enabled + /// and visible. The panel wants specifically "you have made a selection + /// and not yet done anything with it", because that state looks identical + /// to a broken mask and is the most likely thing a first-time user hits. + pub fn mask_is_adjusted(&self, id: &str) -> bool { + self.graph + .masks() + .get(id) + .is_some_and(|l| l.active_ops().next().is_some()) + } + + /// The panel's representative selection — see [`Self::active_layer`] for + /// what "representative" means once more than one layer is selected. + pub fn active_mask(&self) -> Option<&str> { + self.active_masks.first().map(String::as_str) + } + + /// Every selected layer's id, in selection order. + pub fn active_masks(&self) -> &[String] { + &self.active_masks + } + + /// TRACES: FR-DEV-3 | FR-UI-3 + /// The selected gradient's handles, in fractions of the shown image. + /// + /// Empty unless **exactly one** gradient layer is selected. Dragging a + /// shared handle for several gradients at once has no single geometry to + /// move — each one's centre, angle and extent differ — so multi-select + /// simply offers no handles rather than moving one layer's shape while + /// silently leaving the others behind. + /// + /// Recomputed on every redraw rather than cached, because the answer + /// changes with the *view* and not only with the mask: a pan moves every + /// handle and touches no geometry. Four handles through an affine map is + /// not work worth caching, and a cache keyed on the wrong thing is how a + /// handle comes to sit where the mask used to be. + pub fn gradient_handles(&self) -> Vec { + if self.active_masks.len() != 1 { + return Vec::new(); + } + let Some(layer) = self.active_layer() else { + return Vec::new(); + }; + let (sw, sh) = self.demosaiced.size(); + crate::gradient::handles(&layer.base().source, self.graph.framing(), (sw, sh)) + } + + /// Drag one handle of the selected gradient, from `press` to `now`, both + /// in fractions of the shown image. + /// + /// `origin` is the geometry the gesture started from — see + /// [`crate::gradient::drag`] for why a drag is applied to that rather than + /// accumulated. Returns it, so the caller can hold it for the rest of the + /// gesture; `None` when there is no gradient selected to drag. + pub fn drag_gradient_handle( + &mut self, + role: crate::HandleRole, + origin: Option<&MaskSource>, + press: (f32, f32), + now: (f32, f32), + ) -> Option { + if self.active_masks.len() != 1 { + return None; + } + let (sw, sh) = self.demosaiced.size(); + let framing = *self.graph.framing(); + let id = self.active_masks.first()?.clone(); + let start = match origin { + Some(s) => s.clone(), + None => self.graph.masks().get(&id)?.base().source.clone(), + }; + + let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh)); + self.graph.masks_mut().get_mut(&id)?.base_mut().source = moved; + // **Nothing recorded here.** A drag delivers a pointer event a frame, + // and a history step per frame would make undo walk a gesture back + // pixel by pixel. `Edit` coalesces by operation id and a mask's shape + // is not an operation, so there is no key to coalesce under — the + // honest answer is to record once, on release. + Some(start) + } + + /// A handle drag finished: one history step for the whole gesture. + /// + /// Called on the pointer's release rather than on each move, which is what + /// makes a drag one decision in the undo stack however many frames it took. + pub fn commit_gradient_drag(&mut self) { + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_MOVED)); + } + + // --- editing a mask by hand (FR-DEV-19) -------------------------------- + + /// TRACES: FR-DEV-19a + /// Which part of `id` the edge controls act on. + /// + /// The selected part when this is the layer being edited, and the base + /// otherwise — because a panel that is not showing a layer's parts has not + /// offered anybody a way to choose one, and answering with a part they + /// cannot see would make the same slider mean different things depending + /// on what was selected a moment ago. + pub(super) fn shaped_part(&self, id: &str) -> usize { + match self.active_masks.as_slice() { + [only] if only == id => self.active_part, + _ => 0, + } + } + + /// The part of `id` the edge controls read. + pub(super) fn part_of(&self, id: &str) -> Option<&dr_pipeline::mask::MaskPart> { + let index = self.shaped_part(id); + self.graph.masks().get(id)?.part(index) + } + + /// The same, to write through. + pub(super) fn part_of_mut(&mut self, id: &str) -> Option<&mut dr_pipeline::mask::MaskPart> { + let index = self.shaped_part(id); + self.graph.masks_mut().get_mut(id)?.part_mut(index) + } + + /// Which part of the selected layer the tools point at. + pub fn active_part(&self) -> usize { + self.active_part + } + + /// Point the tools at one part, or at the base when the index is past the + /// end — which is what a part being removed under the selection leaves. + pub fn set_active_part(&mut self, index: usize) { + let parts = self.active_layer().map_or(1, |l| l.parts().len()); + self.active_part = if index < parts { index } else { 0 }; + } + + /// The parts of a layer: id, what to call it, and how it joins. + /// + /// The join of the first is meaningless — there is nothing before it to + /// join to — and the panel shows it as the selection the layer *is* + /// rather than as a row with a chip that does nothing. + pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize, bool)> { + let Some(layer) = self.graph.masks().get(id) else { + return Vec::new(); + }; + layer + .parts() + .iter() + .map(|p| { + let join = dr_pipeline::mask::Join::ALL + .iter() + .position(|&j| j == p.join) + .unwrap_or(0); + (p.id.clone(), p.source.kind().to_string(), join, p.hidden) + }) + .collect() + } + + /// TRACES: FR-DEV-19a + /// Leave one part out of the build, or put it back. An edit, and one + /// history step, for the same reason the layer's own switch is: the part + /// really is out until it is switched back. + pub fn set_mask_part_hidden(&mut self, id: &str, index: usize, hidden: bool) { + let Some(layer) = self.graph.masks_mut().get_mut(id) else { + return; + }; + let Some(part) = layer.part_mut(index) else { + return; + }; + if part.hidden == hidden { + return; + } + part.hidden = hidden; + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_PART_TOGGLED)); + } + + /// TRACES: FR-DEV-19a + /// Join a fresh painted part to a layer, returning its index. + /// + /// Painted, because that is the correction a photographer reaches for + /// first and the only source that needs nothing found for it. The other + /// sources arrive when a part can carry its own distance field. + pub fn add_mask_part(&mut self, id: &str, join: usize) -> Option { + use dr_pipeline::mask::{Join, MaskPart}; + let &join = Join::ALL.get(join)?; + let layer = self.graph.masks_mut().get_mut(id)?; + let part_id = layer.next_part_id(); + if !layer.push_part(MaskPart::painted(part_id, join)) { + return None; + } + let index = layer.parts().len() - 1; + self.active_part = index; + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_PART_ADDED)); + Some(index) + } + + /// Take a part back out of a layer. The base is not removable — removing + /// the selection a layer *is* is removing the layer. + pub fn remove_mask_part(&mut self, id: &str, index: usize) { + let Some(layer) = self.graph.masks_mut().get_mut(id) else { + return; + }; + if layer.remove_part(index).is_none() { + return; + } + self.set_active_part(self.active_part.min(index.saturating_sub(1))); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_PART_REMOVED)); + } + + /// Change how a part joins: added to the mask, or taken out of it. + pub fn set_mask_part_join(&mut self, id: &str, index: usize, join: usize) { + use dr_pipeline::mask::Join; + let Some(&join) = Join::ALL.get(join) else { + return; + }; + let Some(layer) = self.graph.masks_mut().get_mut(id) else { + return; + }; + // The first part joins nothing, so saying how it joins would be a + // control that moves and changes no pixel. + if index == 0 { + return; + } + let Some(part) = layer.part_mut(index) else { + return; + }; + part.join = join; + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_JOINED)); + } + + /// The brush: radius, hardness, flow. + pub fn brush(&self) -> (f32, f32, f32) { + self.brush + } + + /// Set the brush. Radius is a fraction of the frame's shorter edge, so it + /// means the same thing on the phone and on the desktop and at any zoom. + pub fn set_brush(&mut self, radius: f32, hardness: f32, flow: f32) { + self.brush = ( + radius.clamp(0.002, 0.5), + hardness.clamp(0.0, 1.0), + flow.clamp(0.01, 1.0), + ); + } + + /// How many stroke points the selected layer may still record. + /// + /// Asked by the panel so that a mask approaching its budget can say so + /// before a gesture is refused mid-stroke — which is the moment the + /// refusal is least explicable. + pub fn mask_room(&self) -> usize { + self.active_layer().map_or(0, |l| l.room()) + } + + /// TRACES: FR-DEV-19b + /// Begin a stroke at a point in fractions of the shown image. + /// + /// **Paints into the active part, or joins one if that part cannot hold a + /// stroke.** Pressing Paint on a mask the model made is the ordinary way + /// this is reached, and it must not answer with a refusal explaining that + /// a subject is not a brush: the correction the photographer is about to + /// make *is* a new part, so it is made. + /// + /// Returns whether a stroke was started. `false` means the layer is full + /// or there is nothing selected, and the caller should not send moves. + pub fn begin_mask_stroke(&mut self, x: f32, y: f32, erase: bool) -> bool { + let Some(id) = self.active_masks.first().cloned() else { + return false; + }; + if self.active_masks.len() != 1 { + // Several layers share the slider drags; a stroke has one target + // and guessing which of three it is would be worse than refusing. + return false; + } + + let paintable = self + .graph + .masks() + .get(&id) + .and_then(|l| l.part(self.active_part)) + .is_some_and(|p| matches!(p.source, MaskSource::Brush { .. })); + if !paintable { + use dr_pipeline::mask::Join; + let join = if erase { Join::Subtract } else { Join::Union }; + let position = Join::ALL.iter().position(|&j| j == join).unwrap_or(0); + if self.add_mask_part(&id, position).is_none() { + return false; + } + } + + let part = self.active_part; + let (radius, hardness, flow) = self.brush; + // An erase stroke inside a part that subtracts would take away from + // what the part removes, which reads backwards. In a subtracting part + // the brush's two modes are already the right way round. + let subtracting = self + .graph + .masks() + .get(&id) + .and_then(|l| l.part(part)) + .is_some_and(|p| p.join == dr_pipeline::mask::Join::Subtract); + let erase = erase && !subtracting; + + let Some(layer) = self.graph.masks_mut().get_mut(&id) else { + return false; + }; + if !layer.begin_stroke(part, erase, radius, hardness, flow) { + return false; + } + self.painting = Some((id, part)); + self.extend_mask_stroke(x, y); + true + } + + /// Carry the stroke to another point, in fractions of the shown image. + /// + /// The point is mapped into normalised **source** coordinates on the way + /// in, through the same framing map the shader applies — so a stroke stays + /// on the thing it was painted on through a zoom, a pan, a crop and a + /// straighten, and lands in an export at any size where it was drawn. + pub fn extend_mask_stroke(&mut self, x: f32, y: f32) { + let Some((id, part)) = self.painting.clone() else { + return; + }; + let (sw, sh) = self.demosaiced.size(); + let (sx, sy) = self.graph.framing().source_at((x, y), sw, sh); + if let Some(layer) = self.graph.masks_mut().get_mut(&id) { + layer.extend_stroke(part, sx, sy); + } + } + + /// Finish the stroke: one history step for the whole gesture. + /// + /// One step, on release, for the reason a handle drag records once — a + /// stroke is a decision, and undo that walked it back dab by dab would + /// make taking a mark back cost as many presses as making it did. + pub fn end_mask_stroke(&mut self) { + let Some((id, part)) = self.painting.take() else { + return; + }; + let erased = self + .graph + .masks() + .get(&id) + .and_then(|l| l.part(part)) + .and_then(|p| p.strokes().last()) + .is_some_and(|s| s.erase); + if let Some(layer) = self.graph.masks_mut().get_mut(&id) { + layer.end_stroke(part); + } + let step = if erased { + labels::step::MASK_ERASED + } else { + labels::step::MASK_PAINTED + }; + self.history.record(&self.graph, Edit::Action(step)); + } + + /// Abandon a stroke that turned out to be something else — a pinch, or a + /// gesture the window cancelled. Nothing is recorded, because nothing + /// happened as far as the photographer is concerned. + pub fn cancel_mask_stroke(&mut self) { + let Some((id, part)) = self.painting.take() else { + return; + }; + if let Some(layer) = self.graph.masks_mut().get_mut(&id) { + layer.drop_last_stroke(part); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::develop::test_support::*; + + // ---------------------------------------------------------------------- + // The overlay's clip rectangle + // ---------------------------------------------------------------------- + // + // The overlay is a source-space picture and the canvas shows whatever the + // crop, the zoom and the pan selected out of that space. Drawn whole it + // stays frame-sized while the photograph moves underneath, which is what + // these pin down. + + /// A session with a segmentation, so the clip has a proxy to measure + /// against. + /// + /// The model finds nothing in flat grey, and that is fine: the clip is + /// computed from the framing and the proxy size, neither of which depends + /// on what was detected. + fn segmented_session(ctx: &GpuContext) -> Option { + let rgba: Vec = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect(); + let mut session = + DevelopSession::open_rgb(ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL) + .expect("session"); + session + .segment(&crate::segmentation::Options::default()) + .ok()?; + Some(session) + } + + /// TRACES: FR-DEV-3 | FR-CAT-8 + /// The whole claim, end to end: what is stored renders what was rendered. + /// + /// A session with a model's coverage in hand draws the mask; the stack it + /// hands the sidecar writer goes through the file and into a session with + /// no model at all; and the two frames must be the same. Anything weaker + /// — that the coverage is present, that it round-trips as bytes — would + /// still pass if the raster came back at the wrong scale, upside down, or + /// a threshold out. + #[test] + fn a_shown_mask_is_only_shown_while_masking() { + let Some(ctx) = headless() else { return }; + let mut s = session_with_a_left_half_subject(&ctx); + let id = s.add_subject_mask(0).expect("a subject layer"); + s.set_overlay(true); + s.set_mask_shown(&id, true); + assert!(s.any_mask_shown(), "lit, in Local mode"); + + // Leaving the mode — what `on_mode_picked` does for Photo and Spots. + s.set_overlay(false); + assert!( + !s.any_mask_shown(), + "the tint belongs to the mode, not to the photograph" + ); + assert!(s.mask_shown(&id), "the eye itself is remembered"); + + s.set_overlay(true); + assert!(s.any_mask_shown(), "and is lit again on return"); + } + + /// TRACES: FR-DEV-3 + /// Multi-select: one slider, applied to every selected layer. + /// + /// `toggle_active_mask` builds the selection a control-click makes, and + /// `set_param`/`reset_op` are what a drag and a reset call — this pins + /// down that both fan out to every layer in it rather than only the + /// first, which is the whole point of selecting more than one. + #[test] + fn a_slider_moved_with_two_layers_selected_moves_both() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) + .expect("session"); + + let a = session.add_gradient_mask(true).expect("first gradient"); + let b = session.add_gradient_mask(false).expect("second gradient"); + // Adding `b` selected it alone — build the multi-selection a + // control-click would, starting from that single-layer state. + session.toggle_active_mask(&a); + assert_eq!(session.active_masks(), [b.clone(), a.clone()].as_slice()); + + assert!( + !session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b), + "neither layer has been touched yet" + ); + + let row = session.rows()[0].clone(); + session.set_param(row.op_index, row.param_index, row.maximum); + + assert!( + session.mask_is_adjusted(&a) && session.mask_is_adjusted(&b), + "one slider, both layers selected, both layers must show the edit" + ); + + // And a reset walks the same set. + session.reset_op(row.op_index); + assert!( + !session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b), + "resetting with both selected must clear both, not just the one \ + the panel happens to read values from" + ); + } + + /// A control-click twice — once to add, once to remove — is a no-op on + /// the selection, which is the sanity check for `toggle_active_mask` + /// itself before trusting anything built on it. + #[test] + fn toggling_a_layer_twice_returns_to_the_starting_selection() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) + .expect("session"); + + let a = session.add_gradient_mask(true).expect("gradient"); + assert_eq!(session.active_masks(), [a.clone()].as_slice()); + + session.toggle_active_mask(&a); + assert!(session.active_masks().is_empty(), "removed by the toggle"); + + session.toggle_active_mask(&a); + assert_eq!(session.active_masks(), [a.clone()].as_slice(), "added back"); + } + + #[test] + fn an_unzoomed_overlay_shows_the_whole_frame() { + let Some(ctx) = headless() else { return }; + let Some(session) = segmented_session(&ctx) else { + eprintln!("no model; skipping"); + return; + }; + + let (x, y, w, h) = session.overlay_clip(); + assert_eq!((x, y), (0, 0)); + assert!(w > 1 && h > 1, "the whole proxy: {w}x{h}"); + } + + /// The bug this exists for: zooming must narrow the clip, or the overlay + /// keeps showing the whole picture at frame size while the canvas shows a + /// detail of it. + #[test] + fn zooming_narrows_the_overlay_to_what_is_visible() { + let Some(ctx) = headless() else { return }; + let Some(mut session) = segmented_session(&ctx) else { + eprintln!("no model; skipping"); + return; + }; + + let (_, _, full_w, full_h) = session.overlay_clip(); + session.zoom_about(4.0, 0.5, 0.5); + let (_, _, zoomed_w, zoomed_h) = session.overlay_clip(); + + assert!( + zoomed_w < full_w && zoomed_h < full_h, + "zoomed in, the overlay should show less: {zoomed_w}x{zoomed_h} \ + against {full_w}x{full_h}" + ); + } + + #[test] + fn panning_moves_the_overlay_with_the_photograph() { + let Some(ctx) = headless() else { return }; + let Some(mut session) = segmented_session(&ctx) else { + eprintln!("no model; skipping"); + return; + }; + + session.zoom_about(4.0, 0.5, 0.5); + let (before_x, _, _, _) = session.overlay_clip(); + session.pan_by(0.3, 0.0); + let (after_x, _, _, _) = session.overlay_clip(); + + assert!( + after_x > before_x, + "panning right moves the visible window right: {before_x} then {after_x}" + ); + } + + #[test] + fn cropping_narrows_the_overlay_too() { + let Some(ctx) = headless() else { return }; + let Some(mut session) = segmented_session(&ctx) else { + eprintln!("no model; skipping"); + return; + }; + + let (_, _, full_w, _) = session.overlay_clip(); + session.set_crop(dr_pipeline::CropRect { + x: 0.25, + y: 0.25, + width: 0.5, + height: 0.5, + }); + let (x, y, w, _) = session.overlay_clip(); + + assert!(w < full_w, "a half-width crop shows half the overlay"); + assert!(x > 0 && y > 0, "and it starts inside the frame"); + } + + /// Nothing segmented means no overlay, and no rectangle a caller might + /// divide by. + #[test] + fn no_segmentation_means_no_clip() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); + let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) + .expect("session"); + assert_eq!(session.overlay_clip(), (0, 0, 0, 0)); + } +} diff --git a/ui/dr-ui/src/develop/mod.rs b/ui/dr-ui/src/develop/mod.rs new file mode 100644 index 0000000..9627a04 --- /dev/null +++ b/ui/dr-ui/src/develop/mod.rs @@ -0,0 +1,203 @@ +//! The develop session — capabilities in, rendered image out. +//! +//! This is the only place the UI touches the pipeline, and it does so through +//! two calls: [`dr_pipeline::EditGraph::capabilities`] to learn what controls +//! to build, and `set_param` to change one. It never names an operation, and +//! it knows nothing about shaders. +//! +//! Whether a control is a slider or a switch follows from the parameter's +//! declared [`ParamKind`], not from which parameter it is (ARCH §4.3), so a +//! new operation appears in the panel with no change here (FR-DEV-3c). +//! +//! Split into one module per area of the session (framing, masks, +//! segmentation, rendering, ...) rather than kept as one file — see +//! `docs/dev/code-health.md` CH-1. `DevelopSession`'s fields are `pub(super)` +//! so its `impl` blocks can live beside the area of behaviour they belong to +//! instead of all in one place; nothing outside this module sees them, since +//! only methods were ever exported. + +mod curves; +mod framing; +mod history; +mod mask_ops; +mod masks; +mod render; +mod repairs; +mod rows; +mod segmentation; +mod session; +mod tabs; +mod white_balance; + +pub use framing::CropAspect; +pub use masks::MASK_COLOURS; +pub use segmentation::{Abandon, RefinedInstance, Segmented, SessionId}; +pub use session::DevelopSession; + +/// Longest edge the model and the masks work at. +/// +/// ~1.3 MP at 3:2. Large enough that an outline is within a pixel or two of +/// where it belongs, small enough that a distance transform over it is a few +/// milliseconds and its field a few megabytes. +pub(super) const SEGMENT_PROXY_EDGE: u32 = 1600; + +/// Test-only helpers shared by more than one of this module's submodules. +/// +/// `headless`, `read_back` and `grey_session` were each defined once in the +/// pre-split file and called from tests all over it. Splitting the tests with +/// the code they exercise left these three needed in most of the resulting +/// files, so they live here once instead of being copied. +#[cfg(test)] +pub(super) mod test_support { + use super::*; + use dr_gpu::GpuContext; + + /// TRACES: FR-DSP-1 | AC-8 + /// Copy a displayed frame back to the CPU, for assertions and nothing else. + /// + /// The library has no such function on purpose: S1 removed the display + /// readback, and AC-8 is the assertion that it stayed removed. A test that + /// wants to look at the pixels therefore has to do the copy itself, which + /// is exactly the right shape — the round-trip lives in the test binary + /// and cannot be reached from a shipping one. + /// + /// Doubles as the proof: this only compiles because the image *is* a wgpu + /// texture. Hand it a `SharedPixelBuffer`-backed image and it panics. + pub(super) fn read_back(ctx: &GpuContext, image: &slint::Image) -> Vec { + let texture = image + .to_wgpu_29_texture() + .expect("the develop canvas must be a GPU texture, not a pixel buffer"); + let (w, h) = (texture.width(), texture.height()); + + // Buffer rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT. + let unpadded = w * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded = unpadded.div_ceil(align) * align; + + let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("test-readback"), + size: u64::from(padded * h), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let mut enc = ctx.device.create_command_encoder(&Default::default()); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + ctx.queue.submit(Some(enc.finish())); + + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + ctx.device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("poll"); + rx.recv().expect("map").expect("map"); + + let data = slice.get_mapped_range(); + let mut out = Vec::with_capacity((unpadded * h) as usize); + for row in 0..h { + let start = (row * padded) as usize; + out.extend_from_slice(&data[start..start + unpadded as usize]); + } + drop(data); + buf.unmap(); + out + } + + /// One device for the whole test binary. + /// + /// This opened a *new* `GpuContext` per test, and `cargo test` runs tests + /// on as many threads as there are cores — so a full run asked the driver + /// to bring up a dozen Vulkan devices at once and the binary died with + /// SIGSEGV. Serially it passed, which is what made it look like flakiness + /// rather than a bug in the harness. + /// + /// A `GpuContext` is an `Arc` and an `Arc`, so sharing one + /// is a refcount rather than a copy, and wgpu is explicit that both are + /// safe to use from several threads. Nothing here mutates the context; the + /// per-test state is in the passes and the sessions built on top of it. + /// + /// `OnceLock` rather than `lazy_static`: the initialiser runs once however + /// many threads arrive together, and the losers block until it is done — + /// which is precisely the property that was missing. + pub(super) fn headless() -> Option { + static SHARED: std::sync::OnceLock> = std::sync::OnceLock::new(); + SHARED + .get_or_init(|| pollster::block_on(dr_gpu::GpuContext::new_headless()).ok()) + .clone() + } + + /// A flat grey session with nothing segmented, and its render. + pub(super) fn grey_session(ctx: &GpuContext) -> (DevelopSession, Vec) { + let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); + let mut session = + DevelopSession::open_rgb(ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) + .expect("session"); + assert!( + !session.has_segmentation(), + "the premise: no model has been run" + ); + let before = read_back(ctx, &session.render(64, 64).expect("render")); + (session, before) + } + + /// A session holding a segmentation with one instance over the left half. + /// + /// Built rather than detected. What these tests need is coverage of a + /// *known* shape, so that "the stored raster renders what the model's did" + /// is a comparison rather than a hope — and asking a real run what it + /// happened to find in a synthetic frame would make the assertion depend + /// on the weights. + pub(super) fn session_with_a_left_half_subject(ctx: &GpuContext) -> DevelopSession { + let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); + let mut session = + DevelopSession::open_rgb(ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) + .expect("session"); + + let (pw, ph) = session.mask_raster_size(); + let (pw, ph) = (pw as usize, ph as usize); + let mut mask = vec![0u8; pw * ph]; + for y in 0..ph { + for x in 0..pw / 2 { + mask[y * pw + x] = 255; + } + } + + session.segmentation = Some(crate::segmentation::Segmentation::for_test( + vec![crate::segmentation::InstanceSummary { + class_name: "dog".into(), + score: 0.9, + mask, + bbox: (0.0, 0.0, (pw / 2) as f32, ph as f32), + }], + Vec::new(), + 0xfeed, + (pw, ph), + )); + session.subjects = None; + session.subject_key = 0; + session + } +} diff --git a/ui/dr-ui/src/develop/render.rs b/ui/dr-ui/src/develop/render.rs new file mode 100644 index 0000000..031c945 --- /dev/null +++ b/ui/dr-ui/src/develop/render.rs @@ -0,0 +1,1418 @@ +//! Turning a session into pixels: the display colour space, the render +//! passes themselves, histograms and focus peaking, export and thumbnails, +//! and the film stock a rendered frame is baked through. +use dr_gpu::{FocusPeaking, Histogram, RawHistogram}; +use dr_pipeline::{CropRect, Edit, Preset, Scope}; + +#[cfg(test)] +use dr_decode::RawImage; + +use crate::labels; + +use super::session::DevelopSession; + +/// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect. +/// +/// Rendering to the letterboxed size rather than the full viewport avoids +/// shading pixels the view will not show, which at a 3:2 image in a 16:9 +/// window is a fifth of them. +pub(super) fn fit(sw: u32, sh: u32, max_w: u32, max_h: u32) -> (u32, u32) { + if sw == 0 || sh == 0 { + return (max_w, max_h); + } + let scale = (max_w as f32 / sw as f32).min(max_h as f32 / sh as f32); + // Never upscale past the source: there is no detail to recover, and a + // 1:1 render is cheaper. + let scale = scale.min(1.0); + ( + ((sw as f32 * scale).round() as u32).max(1), + ((sh as f32 * scale).round() as u32).max(1), + ) +} + +impl DevelopSession { + /// Rasterise the current mask stack, if there is one. + /// + /// Returns `None` for a stack with no active layers, which is the common + /// case and the one that must cost nothing: the adjust pass then binds its + /// own placeholder and the generated shader has no layer block to read it + /// with. + /// Render `shader` at `w`×`h` with this edit's masks bound. + /// + /// **Every path that produces pixels must come through here.** The + /// generated shader always declares the mask binding and always emits a + /// layer block for each active layer; binding the empty placeholder + /// instead multiplies every one of them by zero. That is not an error and + /// logs nothing — the local adjustments simply are not there. Exports and + /// thumbnails both did exactly that. + /// + /// The mask array is rasterised in source space at proxy size and sampled + /// through the framing map, so one array is correct at every output size: + /// a 256px thumbnail and a 24 MP export bind the same texture. + /// + /// **And the detail stage with it.** The neighbourhood operations — noise + /// reduction, capture sharpening, and the rest of FR-DEV-3's kernels — + /// cannot be fused into the single dispatch, so an edit using one composes + /// a fused pass that hands on *linear* values and a chain of passes that + /// finishes the job (see `dr_pipeline::detail`). Those two halves must be + /// composed from one graph and dispatched together, or the fused shader's + /// storage format does not match the texture bound to it; going through + /// `render_detailed` here is what makes that true of every path at once. + /// It falls through to the plain render when the chain is empty, which is + /// almost every edit, so this costs nothing to the frames that do not + /// need it. + /// + /// `space` has to be the space `shader` was composed for. It is the last + /// pass of the detail chain that performs the output transform when there + /// is one, so the two would otherwise be free to disagree about which + /// primaries the file is in — and the result would be a correctly + /// labelled file with the wrong colours in it (FR-EXP-2). + pub(super) fn render_with_masks( + &mut self, + shader: &dr_pipeline::operation::ComposedShader, + w: u32, + h: u32, + space: dr_types::ColourSpace, + ) -> Result<(), String> { + let ctx = self.ctx.clone(); + self.ensure_subject_fields(&ctx); + + let masks = self + .rasterise_masks() + .then(|| self.masks.as_ref().and_then(|p| p.array())) + .flatten(); + + // The neighbourhood stage, composed at the size actually being drawn. + // + // It has to be composed *per render* rather than cached with the edit, + // because a kernel is the one thing in this pipeline that is not + // scale-free: a sharpening radius is stated in source pixels and the + // develop view renders at whatever the viewport needs (FR-DSP-1), so + // the conversion is different for the canvas, the thumbnail and the + // export. `render_scale` works the ratio out from the framing, so a + // crop and a zoom are already accounted for, and zooming to 1:1 + // restores an exact preview with no second render path to maintain. + // + // Empty for every edit with no active neighbourhood operation — which + // is almost all of them — and `render_detailed` then falls straight + // through to the single masked dispatch this used to call. + let detail = self + .graph + .compose_detail_for(self.demosaiced.size(), (w, h), space); + // Detail passes read what the colour pass wrote, so the key they are + // cached against is the colour key: moving a sharpening slider re-runs + // this stage and not the fused one (FR-DEV-3d). + let colour_key = self + .graph + .invalidation() + .through(dr_pipeline::Affects::Colour); + + self.adjust + .render_detailed(&self.demosaiced, shader, w, h, masks, &detail, colour_key) + .map(|_| ()) + .map_err(|e| e.to_string()) + } + + /// TRACES: FR-DSP-8 + /// Encode the canvas for a different display from now on. + /// + /// Returns whether anything changed, so a caller polling for window moves + /// can redraw only when the answer is genuinely different — the poll runs + /// far more often than a monitor is changed, and a redraw per poll would + /// undo the point of rendering on demand. + /// + /// Nothing is invalidated here and nothing needs to be. The next + /// [`Self::render`] composes against the new space, the pipeline cache + /// distinguishes the two shaders by the structure hash the space enters, + /// and the mask array — rasterised in source space, sampled through the + /// framing — is unaffected because a colour space is not a geometry. + pub fn set_display_space(&mut self, space: dr_types::ColourSpace) -> bool { + let changed = self.display_space != space; + self.display_space = space; + changed + } + + /// The space the canvas is currently being encoded into. + pub fn display_space(&self) -> dr_types::ColourSpace { + self.display_space + } + + /// TRACES: FR-DSP-1 | AC-8 + /// Render at the requested display size and hand back a Slint image. + /// + /// Renders at *viewport* resolution rather than sensor resolution, which + /// is what keeps slider interaction inside the frame budget on a 24 MP + /// file (FR-DSP-1). + /// + /// **The image is the texture, not a copy of it.** This used to end in a + /// `read_output` into a `SharedPixelBuffer` — the GPU→CPU→GPU round-trip + /// ARCH §6.1 forbids and AC-8 asserts against, measured at ~7 ms at 4K + /// against a 0.28 ms compute pass. Spike S1 replaced it with + /// `slint::Image::try_from`, which wraps the texture where it already is. + /// The `clone` below is a refcount on the wgpu handle, not on the pixels. + /// + /// This only works because the compositor is drawing with the same device + /// the pass wrote with; see `shared_gpu` in the crate root for how that is + /// arranged, and note that nothing here can detect it having gone wrong — + /// a texture from a foreign device is a runtime fault on a real screen, + /// which is why the arrangement is made once at startup and never again. + pub fn render(&mut self, width: u32, height: u32) -> Result { + // Fit the render to the viewport while preserving aspect, so the + // pass does no work on pixels the view will letterbox away. + // + // Fitted against the *framed* size, not the sensor's: a crop changes + // the aspect ratio, and fitting the uncropped shape would letterbox + // to the wrong box and render the crop squashed. + let (sw, sh) = self.demosaiced.size(); + let (fw, fh) = self.graph.output_size(sw, sh); + let (w, h) = fit(fw, fh, width.max(1), height.max(1)); + + // TRACES: FR-DSP-8 | FR-DSP-6 + // **Composed for the display that is showing this canvas**, not for + // sRGB. This is the whole of FR-DSP-8's second half arriving at the + // pipeline: a display change is a *recomposition* and nothing more, + // because the output space was always a parameter of composition and + // always entered the structure hash. Moving the window to a P3 panel + // therefore costs one shader compile and no pipeline change at all. + let space = self.display_space; + // TRACES: FR-DEV-19c + // **The one composition that may show a mask.** Every other caller of + // the graph — `render_the_file`, the thumbnail — goes through + // `compose_for`, which cannot ask for a reveal, so no exported file + // can carry one; `sample_as_shot` composes no operations at all. + let shader = self.graph.compose_revealing(space, self.reveal().as_ref()); + + // Rasterise the masks first: the shader addresses array slices by + // index, so the array has to describe *this* stack before it is bound. + // + // The same `space` to both, necessarily: where a detail stage exists + // it is the *last* pass that performs the output transform, and two + // halves composed for different spaces would encode the frame twice + // or not at all. + self.render_with_masks(&shader, w, h, space)?; + let texture = self.adjust.output().ok_or("nothing was rendered")?; + + // The import is fallible on format and usage only, and both are fixed + // in `AdjustPass`'s texture descriptor — so a failure here is a + // descriptor that drifted, not anything the caller did. Say that, + // rather than surfacing "InvalidUsage" to a photographer. + #[cfg(not(target_os = "android"))] + { + slint::Image::try_from(texture.clone()) + .map_err(|e| format!("the render target is not importable by the compositor: {e}")) + } + + // Android draws with Skia over OpenGL and cannot sample a + // `wgpu::Texture`, so the frame comes back through memory. See + // `crate::shared_gpu`'s Android arm for why that is the trade on this + // platform. The pass still runs on the GPU; only this last hop does not. + #[cfg(target_os = "android")] + { + let _ = texture; + let (rgba, w, h) = self + .adjust + .export_pixels() + .map_err(|e| format!("reading the rendered frame back: {e}"))?; + let mut buf = slint::SharedPixelBuffer::::new(w, h); + let wanted = (w as usize) * (h as usize) * 4; + let src = &rgba[..wanted.min(rgba.len())]; + buf.make_mut_bytes()[..src.len()].copy_from_slice(src); + Ok(slint::Image::from_rgba8(buf)) + } + } + + /// TRACES: FR-DSP-7 + /// Count the frame that is currently on the canvas. + /// + /// **Reads the frame [`Self::render`] last produced rather than rendering + /// its own.** The histogram has to describe what the photographer is + /// looking at, and rendering a second time to count it would both cost a + /// second pass and open the possibility of the two disagreeing. + /// + /// That the frame is the *displayed* one has two consequences worth being + /// explicit about. It is in the output colour space, which is what + /// FR-DSP-7 asks for — the levels counted are the levels the display will + /// show, so a clipped bin means a highlight that is actually gone rather + /// than one the transform might still recover. Since FR-DSP-8 that is the + /// space of *this display* rather than sRGB, which makes the reading more + /// truthful and not less: a highlight that survives on a wide-gamut panel + /// and clips on the laptop's screen genuinely is two different facts, and + /// the histogram now reports whichever one the photographer is looking at. And when the view is zoomed + /// or cropped it describes the visible region, not the whole file: a + /// photographer inspecting a highlight at 4× is asking about *that* + /// highlight, and a histogram of the parts of the frame off screen would + /// be answering a question nobody asked. + /// + /// `None` where nothing has been rendered yet, or where the device could + /// not build the reduction. + pub fn histogram(&self) -> Option { + let pass = self.histogram.as_ref()?; + let frame = self.adjust.output()?; + pass.compute(frame) + .inspect_err(|e| log::warn!("histogram failed: {e}")) + .ok() + } + + /// TRACES: FR-CULL-3 + /// Whether there is sensor data behind this session at all. + /// + /// False for the JPEG path, where [`DemosaicedImage::from_rgba8`] built + /// the source from an already-rendered image. There is no white level in + /// such a file and so no scale to measure headroom against: the honest + /// answer for one is that the raw instrument has nothing to say, which is + /// a different statement from a device that could not build the pass, and + /// the panel says the two differently. + pub fn has_sensor_data(&self) -> bool { + !self.demosaiced.is_non_linear() + } + + /// TRACES: FR-CULL-3 + /// Count the sensor data this photograph was demosaiced from. + /// + /// **This is the other histogram, not a variant of the one above**, and + /// the two answer questions that a culling decision needs kept apart. + /// [`Self::histogram`] counts the frame on the canvas, after white + /// balance, the camera matrix, the base curve, the tone curve and the + /// output transform: a clipped bin there is a highlight that is gone as + /// the image currently stands. This counts the demosaiced scene-linear + /// texture, before any of that, on an axis of stops below sensor + /// saturation — so a clipped bin here is a highlight that is gone *in the + /// file*, and no edit will bring it back. FR-CULL-3 exists because the + /// tools that offer the second reading do not develop, and the ones that + /// develop offer only the first — "no shipping tool combines both". + /// + /// **It describes the whole frame, not the visible region**, which is the + /// opposite of what [`Self::histogram`] does and deliberate. A crop and a + /// zoom change what is on screen; neither changes what the sensor + /// recorded, and the question this answers — how much latitude does this + /// exposure have — is asked of the capture rather than of the view. + /// + /// Computed once and cached, for the reason `raw_counts` gives. + /// + /// `None` where the file carries no sensor data, or where the device could + /// not build the reduction. The caller distinguishes those with + /// [`Self::has_sensor_data`]. + pub fn raw_histogram(&mut self) -> Option { + if self.raw_counts.is_none() { + if !self.has_sensor_data() { + return None; + } + // Scoped so the shared borrow of the pass and of the source ends + // before the cache is written, rather than relying on the reader + // to see that the two field paths are disjoint. + let counted = { + let pass = self.raw_histogram.as_ref()?; + pass.compute(self.demosaiced.texture()) + .inspect_err(|e| log::warn!("the raw histogram failed: {e}")) + .ok() + }; + self.raw_counts = counted; + } + self.raw_counts.clone() + } + + /// TRACES: FR-CULL-3 + /// Whether this device could build the focus-peaking overlay. + /// + /// Asked by the interface so that it can say the overlay is unavailable + /// rather than offer a switch that does nothing. The same courtesy the + /// histogram is not paid, and should be: a control that silently does + /// nothing is worse than one that is visibly absent. + pub fn peaking_available(&self) -> bool { + self.peak.is_some() + } + + /// TRACES: FR-CULL-3 + /// What the overlay is set to, or `None` when it is off. + pub fn peaking(&self) -> Option { + self.peaking + } + + /// TRACES: FR-CULL-3 + /// Switch the overlay on with these settings, or off. + /// + /// Asking for peaking on a device that could not build the pass leaves it + /// off, so that [`Self::peaking`] never claims something is being drawn + /// that is not. Switching off drops the overlay textures rather than + /// merely stopping drawing them: a resident overlay from the last frame is + /// one interface bug away from being laid over the next photograph. + pub fn set_peaking(&mut self, settings: Option) { + self.peaking = settings.filter(|_| self.peak.is_some()); + if self.peaking.is_none() { + if let Some(pass) = self.peak.as_mut() { + pass.clear(); + } + } + } + + /// TRACES: FR-CULL-3 | NFR-P14 + /// Mark the in-focus regions of the frame that is currently on the canvas. + /// + /// **Reads the frame [`Self::render`] last produced**, exactly as + /// [`Self::histogram`] does and for the same reason: the overlay has to + /// describe what the photographer is looking at, and rendering a second + /// time to measure it would cost a pass and admit the possibility of the + /// two disagreeing about the picture. + /// + /// That the frame is the displayed one is what makes the marks land where + /// the eye is. It is at viewport resolution, cropped and zoomed as the + /// view is, and — the point of FR-CULL-3 — descended from sensor data + /// through the demosaic rather than from the camera's embedded JPEG, whose + /// in-body sharpening this would otherwise be measuring at least as much + /// as the lens. + /// + /// **Call this only after a settled render.** See + /// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame + /// cannot be measured for sharpness. + /// + /// `None` where nothing has been rendered, where peaking is off, or where + /// the device could not build the pass. + pub fn focus_overlay(&mut self) -> Option { + let settings = self.peaking?; + // Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`, + // and holding a shared borrow of `self.adjust` across the mutable + // borrow of `self.peak` would cost a `Self { .. }` destructure to say + // something the clone says in one word. + let frame = self.adjust.output()?.clone(); + let pass = self.peak.as_mut()?; + let overlay = pass + .render(&frame, settings) + .inspect_err(|e| log::warn!("focus peaking failed: {e}")) + .ok()? + .clone(); + + #[cfg(not(target_os = "android"))] + { + // A layer over the canvas rather than a tint in it, so nothing + // here reaches the histogram or an export — see `FocusPeakPass` + // for the whole of that argument. + slint::Image::try_from(overlay) + .inspect_err(|e| log::warn!("the focus overlay is not importable: {e}")) + .ok() + } + + // Android draws with Skia over OpenGL and cannot sample a + // `wgpu::Texture`, so the overlay follows the frame it belongs to back + // through memory (technical-debt.md TD-1). The measurement still + // happens on the GPU; only this last hop does not. + #[cfg(target_os = "android")] + { + let _ = overlay; + let (rgba, w, h) = pass + .read_overlay() + .inspect_err(|e| log::warn!("reading the focus overlay back: {e}")) + .ok()?; + let mut buf = slint::SharedPixelBuffer::::new(w, h); + let wanted = (w as usize) * (h as usize) * 4; + let src = &rgba[..wanted.min(rgba.len())]; + buf.make_mut_bytes()[..src.len()].copy_from_slice(src); + Some(slint::Image::from_rgba8(buf)) + } + } + + /// Render the *whole* frame for the crop overlay to be drawn over. + /// + /// Crop mode cannot use [`Self::render`]: that applies the crop, so the + /// area being cropped away would not be on screen and there would be + /// nothing to drag the handles across. This renders as though the crop + /// were full, and the interface draws the rect and greys the surround. + /// + /// Zoom is suspended too. Panning a zoomed view while also dragging crop + /// handles is two conflicting meanings for one drag, and the handles are + /// placed against the whole frame in any case. + /// + /// Returns the image together with the size it was rendered at, since the + /// overlay has to place its rect against exactly those pixels. + pub fn render_uncropped( + &mut self, + width: u32, + height: u32, + ) -> Result<(slint::Image, u32, u32), String> { + let saved_crop = self.graph.crop(); + let saved_view = self.graph.framing().view(); + + self.graph.set_crop(CropRect::default()); + self.graph.framing_mut().set_view(CropRect::default()); + + let result = self.render(width, height); + + // Restored whatever happened: leaving the graph cropped-to-full on a + // render error would silently discard the user's crop. + self.graph.set_crop(saved_crop); + self.graph.framing_mut().set_view(saved_view); + + let image = result?; + let (sw, sh) = self.demosaiced.size(); + // The uncropped frame still turns with the quarter turns, so the + // overlay's box comes from the framing rather than the sensor. + let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh); + let (rw, rh) = fit(fw, fh, width.max(1), height.max(1)); + Ok((image, rw, rh)) + } + + /// TRACES: FR-DEV-7 + /// Render the photograph as the file has it, with every adjustment off. + /// + /// **What a held comparison shows, and it is not a history state.** + /// FR-DEV-7 asks for the current edit against the unedited original, and + /// the only thing the interface had was [`Self::go_to_history`] — which + /// *changes* the edit rather than previewing against it. A photographer + /// who suspects they have overcooked a frame therefore had to undo, look, + /// and redo, which puts two real steps on the stack at exactly the moment + /// they are least sure of what they are doing. + /// + /// This puts none there. It borrows the graph for the length of one + /// render and hands it back: the same shape [`Self::render_uncropped`] + /// uses for the crop and [`Self::render_the_file`] uses for the zoom, and + /// for the same reason — the graph is the one description of the + /// photograph, so a second rendering of it is a suspension rather than a + /// copy. Nothing is recorded, nothing is marked modified, and a caller + /// asking whether the image differs from its defaults gets the same + /// answer before and after. + /// + /// **The framing stays on**, and that is a decision rather than an + /// oversight. A held before/after is a question about tone and colour — + /// "have I pushed this too far" — and re-cropping the canvas under the + /// photographer's thumb would move the very detail they are comparing. + /// It would also make the view meaningless: the zoom is a rectangle of the + /// *framed* image, so dropping the crop at 4× would show a different part + /// of the photograph rather than the same part unedited. What the crop + /// took away is compared in Compose, which already shows the whole frame. + /// + /// Restored whatever happens, for the reason `render_uncropped` restores + /// its crop: leaving the graph stripped after a failed render would + /// discard the entire edit, silently. + pub fn render_original(&mut self, width: u32, height: u32) -> Result { + let saved = self.graph.state(); + self.strip_adjustments(); + let rendered = self.render(width, height); + let debt = self.graph.set_state(&saved); + self.pay_film_debt(&debt); + rendered + } + + /// TRACES: FR-DEV-7 + /// Take everything off the graph except the shape of the frame. + /// + /// Four removals rather than [`EditGraph::reset`], which would take the + /// framing with it. The empty preset applied at + /// [`Scope::adjustments`] — the scope that is defined as "all of it but + /// the crop" — clears every parameter the photographer can move, and the + /// three things that are not parameters go by hand: local adjustments, + /// repairs, and the film stock. A local adjustment is as much an edit as + /// the slider that drives it, so an "original" still wearing its masks + /// would be answering a different question. + /// + /// **Only ever inside a suspension.** This leaves the graph describing a + /// photograph nobody asked for, so every caller restores from a + /// [`EditGraph::state`] taken first. + pub(super) fn strip_adjustments(&mut self) { + Preset::default().apply(&mut self.graph, Scope::adjustments()); + *self.graph.masks_mut() = dr_pipeline::mask::MaskStack::new(); + *self.graph.spots_mut() = dr_pipeline::SpotSet::new(); + // Through the session rather than the graph: the baked tables live on + // the adjust pass as well, and clearing one without the other is the + // silent disagreement `set_film` exists to prevent. + self.set_film(None); + } + + /// TRACES: FR-PLAT-AND-5 | NFR-RES-1 + /// Give back the GPU memory this session is holding only to be fast. + /// + /// The edit is untouched: the graph and its history are CPU-side by + /// design (ARCH §6.1), so the photograph, the undo stack and the viewport + /// all survive and the next frame simply costs what the first one did. + /// + /// # What is not released, and what it is waiting on + /// + /// The demosaiced source is the largest single allocation a session holds + /// — a 24 MP frame is about 190 MB of `Rgba16Float` — and it is + /// deliberately kept. Dropping it would need the session to be able to + /// rebuild itself from the file, and rebuilding a session from a durable + /// record is FR-PLAT-AND-3, which is not built. Freeing it now would not + /// be an eviction; it would be closing the photograph without telling + /// anyone. Likewise the subject distance fields and the segmentation map: + /// each is guarded by a key recording what it was built from, and freeing + /// one without invalidating its key is the failure `AdjustPass` documents + /// under `colour_key`. + /// + /// So this is the part of the GPU tier that can be given back and asked + /// for again with no other machinery, which is exactly as far as an + /// eviction should go. + pub fn release_gpu_caches(&mut self) { + self.adjust.release_caches(); + } + + /// TRACES: FR-EXP-9 + /// Render at full resolution and hand back the pixels, for an export. + /// + /// **Not the frame on screen.** [`Self::render`] deliberately renders at + /// viewport size, which is what keeps a slider inside the frame budget on + /// a 24 MP file (FR-DSP-1) — and what would make an export of it a soft, + /// screen-sized file. This renders the framed output size instead, so the + /// export is the full-quality path FR-EXP-9 requires. + /// + /// This reads pixels back and [`Self::render`] does not, and that is the + /// whole distinction AC-8 draws: a file is made of bytes on the CPU and + /// there is no path to one that avoids the transfer, whereas a frame on + /// screen had no business making the trip. See `AdjustPass::export_pixels` + /// for the longer version. + /// + /// Leaves one of the pass's two targets at full resolution; it is dropped + /// and reallocated on the second display render after this, since the + /// other target still holds a viewport-sized texture and comes up first. + /// Cheaper than keeping a second pass alive for the exports a session + /// rarely performs. + /// + /// `space` is the output colour space the file will claim. It is chosen + /// here rather than at encode time because the conversion happens in the + /// shader, before the clip to 0..1 — by the time pixels reach the encoder + /// they are in exactly one space, and the only honest thing left to do is + /// label them. Asking for the wrong one is a typed error rather than a + /// mislabelled file (FR-EXP-2). + pub fn render_for_export( + &mut self, + space: dr_types::ColourSpace, + ) -> Result { + let (sw, sh) = self.demosaiced.size(); + let (w, h) = self.graph.output_size(sw, sh); + + let (pixels, rw, rh) = self.render_the_file(w, h, space)?; + dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string()) + } + + /// TRACES: FR-EXP-9 | FR-CAT-9 + /// Render the *photograph*, with the viewport suspended, and read it back. + /// + /// **The one thing separating a file from a frame on screen**, and the + /// reason both file-producing paths go through here rather than composing + /// for themselves. [`Framing::view`](../dr_pipeline/framing/struct.Framing.html#method.view) + /// is not an edit — it is kept out of the sidecar, out of `is_active` and + /// out of `output_size` precisely so that zooming cannot change what the + /// file becomes. But it is folded into `visible_rect`, which is the rect + /// the fused shader's prologue samples, so a path that composes the graph + /// and renders it inherits the zoom whether or not it wanted it. Exporting + /// at 4:1 wrote the middle of the frame magnified to fill the file, at the + /// full output size, with the detail kernels scaled four times over — + /// silently, since every dimension the old guard checked still held. + /// + /// Suspended rather than refused: an export is a thing the photographer + /// asks for *while* inspecting a highlight at 4×, and demanding they zoom + /// out first would be answering a question nobody asked. + /// + /// Restored whatever happens, for the reason [`Self::render_uncropped`] + /// restores it: leaving the graph un-zoomed after a failed export would + /// throw away where the photographer was looking. + pub(super) fn render_the_file( + &mut self, + w: u32, + h: u32, + space: dr_types::ColourSpace, + ) -> Result<(Vec, u32, u32), String> { + let saved_view = self.graph.framing().view(); + self.graph.framing_mut().set_view(CropRect::default()); + + // Composed *inside* the suspension: the view reaches the shader as a + // uniform baked at composition, so composing before this point would + // restore the framing and export the zoom anyway. + let shader = self.graph.compose_for(space); + let rendered = self.render_with_masks(&shader, w, h, space); + + self.graph.framing_mut().set_view(saved_view); + rendered?; + + self.adjust.export_pixels().map_err(|e| e.to_string()) + } + + /// TRACES: FR-CAT-9 + /// Render this edit small, for the grid's thumbnail. + /// + /// **The framed output, not the sensor.** `output_size` is what a crop, a + /// quarter turn, a flip and a straighten all act on, so a thumbnail taken + /// from the raw frame would show the grid a photograph the user no longer + /// has — the right pixels in the wrong shape, still the wrong way up. This + /// is the same path [`Self::render_for_export`] takes, at a size the store + /// wants instead of at full resolution. + /// + /// Always sRGB: this is going into a JPEG in a thumbnail shard that syncs + /// between devices and is drawn as a cell, not a file the user is + /// finishing. The wider spaces exist for export and mean nothing here. + /// + /// Returns width, height and RGBA8. + /// TRACES: FR-DEV-3f + /// The stocks this build can offer, "no film" first. + /// + /// First rather than last so that index zero is the neutral choice: a + /// photograph that has never been put on film selects it without anyone + /// inventing a sentinel, and `reset` means what it means everywhere else. + /// + /// Only what goes in a camera. A print paper is a stock in the database + /// and is chosen *for* a negative rather than instead of one, and so is a + /// cine projection print film — which is coated on film, and is why the + /// filter asks about the stage rather than the support. + pub fn film_choices() -> Vec<(Option<&'static str>, String)> { + let mut out = vec![(None, "None".to_string())]; + out.extend(dr_film::camera_stocks().map(|p| (Some(p.stock.as_str()), p.name.clone()))); + out + } + + /// TRACES: FR-DEV-3f + /// Develop on a named stock, printed or scanned. + /// + /// Baking is milliseconds and happens here rather than being cached, + /// because the tables depend on the exposure parameters as well as the + /// stock: they are what the enlarger was set to, and a cache keyed on the + /// name alone would hand back somebody else's print. + /// + /// A name this build has no profile for clears the film and says so. That + /// is the sync case — a sidecar written on a device with a stock this one + /// lacks — and rendering it as *some other* film would be worse than + /// rendering it plainly. + /// TRACES: FR-DEV-3f | FR-DEV-5 + /// Choose a stock **as the photographer just did**, and record the step. + /// + /// Separate from [`Self::choose_film`] because that call has two very + /// different callers. Picking Portra from the list is an edit and belongs + /// in the history; the same call made while *restoring* an edit — opening + /// a photograph, or stepping to a history row that names a stock — is the + /// second half of putting a state back, and recording it would push a step + /// for the undo the photographer had just asked for. + /// + /// Choosing a stock was not undoable at all before this existed: the pick + /// went straight to `choose_film`, which nothing on the history's path + /// ever sees. + pub fn pick_film(&mut self, stock: Option<&str>, print: bool) { + self.choose_film(stock, print); + self.history + .record(&self.graph, Edit::Action(labels::step::FILM)); + } + + pub fn choose_film(&mut self, stock: Option<&str>, print: bool) { + let Some(stock) = stock else { + self.set_film(None); + return; + }; + let Some(profile) = dr_film::find(stock) else { + log::warn!("no film profile named {stock}; developing without one"); + self.set_film(None); + return; + }; + + // Only a negative has a paper. Asking to print a reversal stock is not + // an error to report, it is a request that has no meaning — so it is + // quietly the same as not asking. + let paper = if print { + dr_film::default_print(profile) + } else { + None + }; + // TRACES: FR-DEV-3f + // Grain, at the scale this photograph is being sampled at. + // + // A digital frame has no film format, so simulating one means choosing + // what it *would have been* — 35 mm, because that is the format every + // published granularity figure and every intuition about how grainy a + // stock looks comes from. The sensor's width in pixels then says how + // much film one pixel covers, and the grain model needs nothing else + // to be correct at any zoom. + // TRACES: FR-DEV-3f + // The frame this is being simulated on, against the pixels it is being + // rendered to: together they are the enlargement, and the enlargement + // is what decides how grainy the result looks. A crystal is a fixed + // size in micrometres — the same emulsion on a sheet averages far more + // of them into each pixel than it does on 35 mm. + let format = dr_film::Format::from_index( + self.graph + .param( + dr_pipeline::ops::film_sim::ID, + dr_pipeline::ops::film_sim::FORMAT, + ) + .unwrap_or(0.0) + .max(0.0) as usize, + ); + let (source_width, _) = self.demosaiced.size(); + let pixel_size_um = format.width_um() / source_width.max(1) as f32; + let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um); + + let baked = dr_film::bake(&dr_film::Recipe { + film: profile, + print: paper, + exposure_ev: self + .graph + .param( + dr_pipeline::ops::film_sim::ID, + dr_pipeline::ops::film_sim::EXPOSURE, + ) + .unwrap_or(0.0), + push_stops: self + .graph + .param( + dr_pipeline::ops::film_sim::ID, + dr_pipeline::ops::film_sim::PUSH, + ) + .unwrap_or(0.0), + print_exposure_ev: self + .graph + .param( + dr_pipeline::ops::film_sim::ID, + dr_pipeline::ops::film_sim::PRINT_EXPOSURE, + ) + .unwrap_or(0.0), + }); + + self.set_film(Some(dr_pipeline::graph::Film { + stock: profile.stock.clone(), + print: paper.map(|p| p.stock.clone()), + tables: dr_pipeline::ops::FilmTables { + exposure_matrix: baked.exposure_matrix, + curves: baked.curves, + curve_log_min: baked.curve_log_min, + curve_log_max: baked.curve_log_max, + lut: baked.lut, + density_max: baked.density_max, + lut_size: baked.lut_size, + grain_particles: grain.particles, + grain_density_max: grain.density_max, + grain_uniformity: grain.uniformity, + }, + })); + } + + /// The stock and paper currently chosen, by id. + pub fn film(&self) -> Option<(&str, bool)> { + self.graph + .film() + .map(|f| (f.stock.as_str(), f.print.is_some())) + } + + /// Re-bake if `op_index` names the film, and do nothing otherwise. + /// + /// `op_index` counts over [`Self::scoped_capabilities`] — the same list + /// [`Self::lookup`] resolves a slider through — so that is the only list to + /// ask. An earlier version also indexed `rows()`, which is one entry per + /// *parameter* and filtered by the active tab: past its end the check + /// short-circuited, the tables were never rebuilt, and the film's own + /// sliders moved nothing at all. + /// + /// The test is here rather than at the call site so the callback in + /// `lib.rs` goes on naming no operation, which is the rule the whole panel + /// is built on (ARCH §4.3a). + pub fn rebake_film_if_affected(&mut self, op_index: i32) { + let is_film = usize::try_from(op_index) + .ok() + .and_then(|i| self.scoped_capabilities().get(i).map(|c| c.id)) + .is_some_and(|id| id == dr_pipeline::ops::film_sim::ID); + if is_film { + self.rebake_film(); + } + } + + /// The stock, the paper, and how far it was developed. + /// + /// Push rides with the other two through every path that re-bakes, because + /// it is the same kind of fact: a decision about the material rather than + /// an adjustment to the picture it produced. + pub fn rebake_film(&mut self) { + if let Some((stock, print)) = self.film().map(|(s, p)| (s.to_string(), p)) { + self.choose_film(Some(&stock), print); + } + } + + /// TRACES: FR-DEV-3f + /// Choose the film stock this session renders through, or clear it. + /// + /// One call, because two places have to agree and they fail *silently* + /// apart. The graph decides whether the generated shader reads the film + /// textures at all; the pass decides what is bound to them. A graph + /// carrying a stock with a pass that is not carrying one samples the 1x1 + /// placeholders, which is a black frame and an error message from nobody. + /// + /// Nothing downstream needs to know the order, so it is fixed here: the + /// pass first, so that the textures are resident before any shader + /// composed from the graph can be dispatched against them. + pub fn set_film(&mut self, film: Option) { + self.adjust.set_film(film.as_ref().map(|f| &f.tables)); + self.graph.set_film(film); + } + + pub fn render_thumbnail(&mut self, edge: u32) -> Result<(u32, u32, Vec), String> { + let (sw, sh) = self.demosaiced.size(); + let (fw, fh) = self.graph.output_size(sw, sh); + let (w, h) = fit(fw, fh, edge.max(1), edge.max(1)); + + let (pixels, rw, rh) = self.render_the_file(w, h, dr_types::ColourSpace::Srgb)?; + Ok((rw, rh, pixels)) + } + + /// TRACES: FR-DEV-3f | FR-DEV-5 + /// Pay what a restored edit owes the picture. + /// + /// `dr-pipeline` restores a stock's *name* and clears its tables, because + /// baking needs the profile database it does not link (ARCH §6.5a). This + /// side of the seam has it, so this is where the photograph gets its film + /// back. + /// + /// Both outcomes go through [`Self::set_film`], and the empty one is not + /// a no-op: `set_state` cleared the *graph*, and the adjust pass would go + /// on holding textures that nothing will sample. That is the two halves + /// disagreeing, which is the failure `set_film` exists to make + /// impossible — and it is silent in this direction, which is worse. + /// + /// Baked unconditionally rather than only when the stock changed: the + /// tables come from the film node's own exposure sliders as well as from + /// the stock, and restoring an edit replaces those sliders too. A bake is + /// milliseconds and this happens on a keypress, so the cheap correct rule + /// beats the clever one. + pub(super) fn pay_film_debt(&mut self, rebake: &dr_pipeline::FilmRebake) { + match rebake.wanted() { + Some(film) => { + let stock = film.stock.clone(); + let print = film.print.is_some(); + self.choose_film(Some(&stock), print); + } + None => self.set_film(None), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::develop::test_support::*; + + /// TRACES: FR-DEV-7 | FR-DEV-5 + /// A held comparison hands the edit straight back. + /// + /// This is the whole difference between comparing and the + /// undo-look-redo that had to stand in for it. Two steps on the stack, at + /// the moment a photographer is least sure of what they are doing, was + /// the price of looking — and this asserts the price is now nothing: + /// the same parameters, the same history, still modified. + #[test] + fn showing_the_original_leaves_the_edit_exactly_as_it_was() { + let Some(ctx) = headless() else { return }; + let (mut session, _) = grey_session(&ctx); + + // The first control that actually moves the picture — addressed by + // index, so this still names no operation (FR-DEV-3a). + // + // Deliberately not row zero. `EditGraph::capabilities` puts the lens + // corrections first, matching where they sit in the shader, and those + // carry profile coefficients rather than parameters: with no profile + // loaded, driving one to its maximum leaves the graph neutral. Taking + // the first row blindly made the premise below fail for a reason that + // has nothing to do with what is being asserted. + let rows = session.rows(); + let row = rows + .iter() + .find(|row| { + session.set_param(row.op_index, row.param_index, row.maximum); + !session.is_neutral() + }) + .expect("some control in the panel moves the picture") + .clone(); + let _ = row; + + let edit = session.copy_settings(); + let steps = session.history_rows().len(); + assert!(!session.is_neutral(), "the premise: there is an edit"); + + session + .render_original(64, 64) + .expect("render the original"); + + assert_eq!( + session.copy_settings(), + edit, + "every parameter comes back where it was" + ); + assert_eq!( + session.history_rows().len(), + steps, + "looking is not a step to take back" + ); + assert!( + !session.is_neutral(), + "and the photograph is still modified" + ); + } + + /// TRACES: FR-DSP-1 | AC-8 + #[test] + fn the_displayed_frame_is_a_texture_and_not_a_pixel_buffer() { + // The acceptance criterion itself, asserted from the side that would + // notice it regressing. `to_rgba8` returning `Some` would mean the + // frame had come back through system memory to be looked at, which is + // the ~7 ms per frame at 4K that ARCH §6.1 forbids; `to_wgpu_29_texture` + // returning `Some` means the compositor got the texture where it lay. + // + // Note this passes without a display: the import is a wrapper, and it + // is the *compositor* adopting the device that needs a screen. What + // cannot be proved here is that the picture arrives; what can be + // proved is that no copy was made on the way. + let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { + log::warn!("no GPU adapter; skipping"); + return; + }; + + let rgba = vec![128u8; 32 * 32 * 4]; + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL) + .expect("session"); + let frame = session.render(32, 32).expect("render"); + + assert!( + frame.to_rgba8().is_none(), + "the canvas has CPU pixels, so something copied them there" + ); + let texture = frame + .to_wgpu_29_texture() + .expect("the canvas is neither a texture nor a pixel buffer"); + assert_eq!((texture.width(), texture.height()), (32, 32)); + } + + /// TRACES: FR-DSP-1 | AC-8 + #[test] + fn consecutive_frames_look_different_to_the_property_system() { + // The catch that comes free with handing over a texture instead of a + // buffer. Slint repaints when the image property *changes*, and it + // decides that with `PartialEq` — which for two images over one + // `wgpu::Texture` says "unchanged". A pass that reused a single target + // would therefore render every slider move correctly and show none of + // them. + // + // `AdjustPass` alternates between two targets to prevent it. This + // asserts the consequence in the terms Slint actually uses, so it + // would still catch the regression if the mechanism were replaced. + let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { + log::warn!("no GPU adapter; skipping"); + return; + }; + + let rgba = vec![128u8; 32 * 32 * 4]; + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL) + .expect("session"); + + let first = session.render(32, 32).expect("first render"); + let second = session.render(32, 32).expect("second render"); + assert_ne!( + first, second, + "the canvas property would not change, so the frame would never be shown" + ); + } + + #[test] + fn fitting_preserves_aspect_ratio() { + // A 3:2 image in a 16:9 window must letterbox, not stretch. + let (w, h) = fit(6000, 4000, 1600, 900); + assert_eq!(h, 900); + assert!( + ((w as f32 / h as f32) - 1.5).abs() < 0.01, + "got {w}x{h}, aspect {}", + w as f32 / h as f32 + ); + } + + #[test] + fn fitting_never_upscales_past_the_source() { + // Rendering a 400px image into a 4K window at 4K shades 25x the + // pixels for no additional detail. + let (w, h) = fit(400, 300, 3840, 2160); + assert_eq!((w, h), (400, 300)); + } + + #[test] + fn fitting_handles_a_degenerate_source() { + let (w, h) = fit(0, 0, 800, 600); + assert_eq!((w, h), (800, 600)); + } + + #[test] + fn fitting_is_bounded_by_the_narrow_axis() { + // A tall window on a wide image must be limited by width. + let (w, h) = fit(4000, 1000, 800, 4000); + assert_eq!(w, 800); + assert_eq!(h, 200); + } + + /// TRACES: FR-DEV-3 + /// A lone parameter is titled by its operation, so several cannot collide. + /// + /// The panel draws no heading over a group of one, on the argument that a + /// lone control names itself. Three operations declare a single parameter + /// called `amount` — the name `ops/README.md` tells an author to reach for + /// first — and they reached the Detail group as three consecutive sliders + /// all reading "Amount", which is a panel a photographer cannot use. + /// + /// Asserted over the real chain rather than a fixture, because the failure + /// was a property of what is actually declared: a fixture would have to be + /// written to reproduce it and would then only prove itself. + /// TRACES: FR-DEV-3f + /// The film stock is offered in its own group and in "All", nowhere else. + /// + /// It is not a parameter, so it is not a row, so the filter that hides + /// every other control when a group is chosen never saw it: the picker sat + /// at the top of Light, of Colour and of Detail alike. Three places it does + /// not belong, and the one it does no more prominent than the rest. + /// + /// Asserted against whatever the operation actually declares rather than + /// against a named group, so a stock re-declared as something else moves + /// here on its own and this test still describes the rule. + #[test] + fn the_film_stock_is_offered_only_where_it_belongs() { + let Some(ctx) = headless() else { return }; + let (mut session, _) = grey_session(&ctx); + + let film = session + .graph + .capabilities() + .into_iter() + .find(|c| c.id == dr_pipeline::ops::film_sim::ID) + .expect("the film stock is in the chain"); + + session.set_active_tab(-1); + assert!( + session.film_in_group(), + "\"All\" hides nothing, so the stock is offered there" + ); + + for (i, (attribute, label)) in session.tabs().into_iter().enumerate() { + session.set_active_tab(i as i32); + let belongs = film.attributes.contains(&attribute); + assert_eq!( + session.film_in_group(), + belongs, + "the stock is offered in {label} but the operation does not claim it" + ); + } + } + + /// A frame black on the left half and white on the right, at `size` + /// square. Both ends of the histogram are occupied and both clipping + /// counters are non-zero, and cropping to one half leaves exactly one of + /// them so. + fn split_frame(size: u32) -> Vec { + let mut rgba = Vec::with_capacity((size * size * 4) as usize); + for _ in 0..size { + for x in 0..size { + let v = if x < size / 2 { 0u8 } else { 255 }; + rgba.extend_from_slice(&[v, v, v, 255]); + } + } + rgba + } + + /// TRACES: FR-DSP-7 + #[test] + fn the_histogram_counts_the_frame_that_is_actually_on_the_canvas() { + // The wiring, end to end and against exact numbers: a 64x64 frame that + // is half black and half white must come back as 2048 pixels at level + // 0, 2048 at 255, and both clipping counters at 2048. + // + // Asserted at the session rather than at the pass because the mistake + // this catches is not arithmetic — `dr_gpu` has its own tests for that + // — it is counting the *wrong texture*. Reading a stale target, or the + // demosaiced source instead of the adjusted output, produces a + // perfectly well-formed histogram of an image the photographer is not + // looking at, which is the one failure mode that cannot be seen. + let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { + log::warn!("no GPU adapter; skipping"); + return; + }; + + let rgba = split_frame(64); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) + .expect("session"); + session.render(64, 64).expect("render"); + + let hist = session.histogram().expect("a rendered session must count"); + assert_eq!(hist.pixels(), 64 * 64); + assert_eq!(hist.red()[0], 2048, "the black half"); + assert_eq!(hist.red()[255], 2048, "the white half"); + assert_eq!(hist.clipped_shadows(), 2048); + assert_eq!(hist.clipped_highlights(), 2048); + } + + /// TRACES: FR-DSP-7 + #[test] + fn the_histogram_follows_the_edit_rather_than_the_file() { + // The property that makes it *live*. A histogram computed once from the + // source would pass the test above and be useless — the whole reason + // FR-DSP-7 exists is to show what an adjustment is doing, so cropping + // away the white half must leave a histogram with no white in it and + // no highlight clipping to report. + let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { + log::warn!("no GPU adapter; skipping"); + return; + }; + + let rgba = split_frame(64); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) + .expect("session"); + + session.set_crop(CropRect { + x: 0.0, + y: 0.0, + width: 0.5, + height: 1.0, + }); + session.render(64, 64).expect("render"); + + let hist = session.histogram().expect("histogram"); + assert_eq!(hist.pixels(), 32 * 64, "the crop halved the frame"); + assert_eq!(hist.red()[0], 32 * 64); + assert_eq!(hist.red()[255], 0, "the white half was cropped away"); + assert_eq!(hist.clipped_highlights(), 0); + assert_eq!(hist.clipped_shadows(), 32 * 64); + } + + /// A flat Bayer frame whose every photosite normalises to `level`. + /// + /// Black at zero and a power-of-two white level, so the normalisation is + /// exact and the value the raw histogram sees is the one this asked for + /// rather than one rounded by two divisions. + fn flat_raw(size: u32, level: f32) -> RawImage { + const WHITE: u16 = 16384; + let sample = (level * f32::from(WHITE)).round() as u16; + RawImage { + width: size, + height: size, + data: vec![sample; (size * size) as usize], + cfa_pattern: dr_decode::CfaPattern::Rggb, + black_level: [0; 4], + white_level: WHITE, + wb_coeffs: [1.0, 1.0, 1.0, 1.0], + color_matrix: None, + base_curve: dr_decode::BaseCurve::IDENTITY, + samples_per_pixel: 1, + profile: None, + make: String::new(), + model: String::new(), + crop: dr_decode::CropRect { + x: 0, + y: 0, + width: size, + height: size, + }, + } + } + + /// TRACES: FR-CULL-3 + #[test] + fn the_raw_histogram_describes_the_file_and_not_the_view() { + // **The property that makes it a second instrument rather than a + // second rendering of the first**, and the one every other test here + // would pass without. The display histogram beside it deliberately + // follows the edit and the visible region — that is what FR-DSP-7 + // asks of it. This must do neither: cropping away half the photograph + // changes what is on the canvas and changes nothing about what the + // sensor recorded, and a culler asking how much latitude an exposure + // has is asking about the capture. + // + // Reading the adjusted output by mistake would pass a plausible-looking + // plot back — which is exactly why this asserts the *denominator* and + // the bin, not merely that something was counted. + let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { + log::warn!("no GPU adapter; skipping"); + return; + }; + + // 0.234253 is the centre of the bin 33 sixteenths below saturation — + // mid-bin on purpose, so the assertion is about the reduction rather + // than about how this machine's `log2` rounds an exact tie. + let raw = flat_raw(16, 3838.0 / 16384.0); + let mut session = + DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); + + let before = session.raw_histogram().expect("a raw session must count"); + assert_eq!(before.pixels(), 16 * 16); + assert_eq!( + before.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1 - 33], + 16 * 16, + "a flat frame two stops down did not land in one bin" + ); + assert_eq!(before.saturated(), 0, "nothing here is at the white level"); + assert_eq!(before.at_black(), 0); + + session.set_crop(CropRect { + x: 0.0, + y: 0.0, + width: 0.5, + height: 1.0, + }); + session.render(16, 16).expect("render"); + + // The display histogram followed the crop, as it is supposed to. + // Asserted as an inequality rather than an exact figure: how a half + // crop of a 16px frame rounds to a viewport is `AdjustPass`'s + // business and has its own tests, and pinning it here would make this + // test fail for a reason it is not about. + let shown = session.histogram().expect("histogram"); + assert!( + shown.pixels() < 16 * 16, + "the crop did not reach the display histogram, so this proves nothing" + ); + + // The raw one did not. + let after = session.raw_histogram().expect("raw histogram"); + assert_eq!( + after, before, + "the raw reading followed the crop, so it is measuring the render" + ); + } + + /// TRACES: FR-CULL-3 + #[test] + fn a_blown_frame_reads_as_clipped_in_the_raw_domain() { + // The other end, and the reason the requirement exists. Every + // photosite at the white level is a photograph with no highlight + // headroom left in the file — no edit recovers it — and the instrument + // has to say so in the same terms whatever the develop chain currently + // makes of it. + let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { + log::warn!("no GPU adapter; skipping"); + return; + }; + + let raw = flat_raw(16, 1.0); + let mut session = + DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); + + let hist = session.raw_histogram().expect("raw histogram"); + assert_eq!(hist.pixels(), 16 * 16); + assert_eq!(hist.saturated(), 16 * 16); + assert_eq!(hist.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1], 16 * 16); + } + + /// TRACES: FR-CULL-3 + #[test] + fn a_file_with_no_sensor_data_has_no_raw_reading_rather_than_a_wrong_one() { + // The JPEG path. Its source texture is gamma-encoded and carries no + // white level, so there is no scale to measure headroom against — and + // counting it anyway would produce a confident plot of a quantity that + // does not exist, which is the failure mode an instrument must not + // have. The panel says there is nothing to say. + let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { + log::warn!("no GPU adapter; skipping"); + return; + }; + + let rgba = split_frame(16); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) + .expect("session"); + + assert!(!session.has_sensor_data()); + assert!(session.raw_histogram().is_none()); + } + + // ---------------------------------------------------------------------- + // Per-display colour (FR-DSP-8) + // ---------------------------------------------------------------------- + + /// TRACES: FR-DSP-8 | FR-DSP-6 + /// The canvas is encoded for the display, not always for sRGB. + /// + /// This is the assertion the requirement is actually about. Before it, + /// `render` composed `ColourSpace::Srgb` unconditionally, and a second + /// monitor with a different profile got sRGB pixels *labelled* as its own + /// space by the compositor — the silent wrongness FR-DSP-8 calls a + /// correctness defect. If someone re-hardcodes the space, these pixels + /// stop differing and this fails. + /// + /// A saturated red is the probe deliberately: it sits near the edge of + /// sRGB's gamut, so re-encoding it into a wider one moves it a long way, + /// where a mid grey would move by almost nothing in any of the four and + /// the test would pass on a broken build. + #[test] + fn the_canvas_is_encoded_for_the_display_showing_it() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..64 * 64).flat_map(|_| [230u8, 20, 20, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) + .expect("session"); + + assert_eq!( + session.display_space(), + dr_types::ColourSpace::Srgb, + "a session starts on the fallback, so nothing changes for a \ + desktop whose display server will not say otherwise" + ); + let on_srgb = read_back(&ctx, &session.render(64, 64).expect("render")); + + assert!(session.set_display_space(dr_types::ColourSpace::AdobeRgb)); + let on_wide = read_back(&ctx, &session.render(64, 64).expect("render")); + + assert_ne!( + on_srgb, on_wide, + "the same edit rendered for two displays produced the same pixels" + ); + + // And back again, because a photographer dragging a window between + // two monitors expects the first one to look as it did rather than to + // accumulate a transform. + assert!(session.set_display_space(dr_types::ColourSpace::Srgb)); + let returned = read_back(&ctx, &session.render(64, 64).expect("render")); + assert_eq!(on_srgb, returned); + } + + /// TRACES: FR-DSP-8 + /// A move that changes nothing reports nothing, so nothing is redrawn. + /// + /// The window's position is polled twice a second and two displays often + /// share a profile. A setter that reported a change every time it was + /// called would turn that poll into a redraw loop. + #[test] + fn setting_the_same_display_space_twice_is_not_a_change() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) + .expect("session"); + + assert!(session.set_display_space(dr_types::ColourSpace::DisplayP3)); + assert!(!session.set_display_space(dr_types::ColourSpace::DisplayP3)); + assert_eq!(session.display_space(), dr_types::ColourSpace::DisplayP3); + } + + /// TRACES: FR-DSP-8 | FR-EXP-2 + /// The display's space is the *canvas's*, and reaches nothing else. + /// + /// A thumbnail goes into a shard that syncs between devices and an export + /// claims the space the export dialogue asked for. Letting the monitor in + /// front of the photographer decide either would write a file whose + /// profile describes the desk it was made at. + #[test] + fn a_wide_gamut_monitor_does_not_reach_the_thumbnail_or_the_export() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..32 * 32).flat_map(|_| [230u8, 20, 20, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL) + .expect("session"); + + let thumb_before = session.render_thumbnail(16).expect("thumbnail"); + let export_before = session + .render_for_export(dr_types::ColourSpace::Srgb) + .expect("export") + .rgba; + + session.set_display_space(dr_types::ColourSpace::ProPhoto); + + assert_eq!( + session.render_thumbnail(16).expect("thumbnail"), + thumb_before, + "the grid's thumbnail followed the monitor" + ); + assert_eq!( + session + .render_for_export(dr_types::ColourSpace::Srgb) + .expect("export") + .rgba, + export_before, + "an sRGB export followed the monitor" + ); + } +} diff --git a/ui/dr-ui/src/develop/repairs.rs b/ui/dr-ui/src/develop/repairs.rs new file mode 100644 index 0000000..2aca885 --- /dev/null +++ b/ui/dr-ui/src/develop/repairs.rs @@ -0,0 +1,241 @@ +//! Spot removal (FR-DEV-8): placing, dragging and clearing heal/clone spots. +use dr_pipeline::Edit; + +use crate::labels; + +use super::session::DevelopSession; + +impl DevelopSession { + // --- repairs (FR-DEV-8) ------------------------------------------------ + + /// TRACES: FR-DEV-8 + /// Cover what is at `(x, y)`, in fractions of the shown image. + /// + /// The click arrives in *output* coordinates — where the photograph + /// currently sits on screen — and a repair is stored against the + /// photograph, so it goes through `Framing::source_at`: the same map the + /// shader applies, run backwards. Anything less would put the repair where + /// the pointer was rather than where the mark is, and the two agree only at + /// fit-to-window with no crop. + /// + /// Returns the new repair's id, or `None` when the set is full. Selecting + /// it is deliberate: the control that changes its size is in the column, + /// and a photographer who has just placed a spot too small should find that + /// control already pointed at it. + pub fn place_spot(&mut self, x: f32, y: f32) -> Option { + let (sw, sh) = self.demosaiced.size(); + let centre = self.graph.framing().source_at((x, y), sw, sh); + // Outside the photograph entirely — the letterbox margin, or a drag + // that ended off the edge. Placing a repair there would put a disc + // somewhere the user cannot see and cannot pick up again. + if !(0.0..=1.0).contains(¢re.0) || !(0.0..=1.0).contains(¢re.1) { + return None; + } + + let aspect = sw.max(1) as f32 / sh.max(1) as f32; + let radius = dr_pipeline::spot::DEFAULT_RADIUS; + let offset = dr_pipeline::Spot::default_offset(centre, radius, aspect); + + let id = self + .graph + .spots_mut() + .place(dr_pipeline::Spot::new(centre, offset, radius))?; + self.selected_spot = Some(id.clone()); + self.history + .record(&self.graph, Edit::Action(labels::step::SPOT_PLACED)); + Some(id) + } + + /// TRACES: FR-DEV-8 | FR-UI-3 + /// Every repair as a circle on the shown image, plus the source circle of + /// the selected one. + /// + /// # Why only the selected repair shows its source + /// + /// A dusty sky carries a dozen repairs. Two dozen circles with nothing + /// saying which source belongs to which disc is not more information, it is + /// less — and there is no room on a phone for a connector between each + /// pair. The selection is what disambiguates them, which is also why a + /// press on a repair selects it before the drag begins. + /// + /// Recomputed per redraw rather than cached, for the reason + /// [`Self::gradient_handles`] gives: the answer changes with the *view*, + /// and a pan moves every circle while touching no edit. + pub fn spot_handles(&self) -> Vec { + let (sw, sh) = self.demosaiced.size(); + let framing = self.graph.framing(); + let aspect = sw.max(1) as f32 / sh.max(1) as f32; + // The shown image's own shape, which is not the source's once the frame + // has been cropped or turned. A radius is reported against its height, + // so this is what converts the x half of the mapped offset. + let (ow, oh) = self.graph.output_size(sw, sh); + let shown_aspect = ow.max(1) as f32 / oh.max(1) as f32; + + let mut handles = Vec::new(); + for spot in self.graph.spots().spots() { + let selected = self.selected_spot.as_deref() == Some(spot.id.as_str()); + let centre = framing.output_at(spot.centre, sw, sh); + + // The radius, mapped rather than scaled: a point one radius above + // the centre goes through the same map, and the distance between + // the two answers is the radius as drawn. The x half is multiplied + // by the shown aspect because the two axes are normalised by + // different lengths, and a circle measured in mixed units is an + // ellipse. + let rim = framing.output_at((spot.centre.0, spot.centre.1 + spot.radius), sw, sh); + let radius = ((rim.0 - centre.0) * shown_aspect).hypot(rim.1 - centre.1); + + handles.push(crate::SpotHandle { + id: spot.id.clone().into(), + role: crate::SpotRole::Destination, + x: centre.0, + y: centre.1, + radius, + selected, + enabled: spot.enabled, + }); + + if selected { + let source = framing.output_at(spot.source(aspect), sw, sh); + handles.push(crate::SpotHandle { + id: spot.id.clone().into(), + role: crate::SpotRole::Source, + x: source.0, + y: source.1, + radius, + selected: true, + enabled: spot.enabled, + }); + } + } + handles + } + + /// TRACES: FR-DEV-8 + /// Drag one circle of one repair, from `press` to `now`, both in fractions + /// of the shown image. + /// + /// Dragging the disc moves the whole repair and carries its source along — + /// what a photographer means by nudging a spot. Dragging the source moves + /// the source alone, which is the override FR-DEV-8 asks for over the + /// automatic placement. + /// + /// `origin` is the repair as it stood when the gesture began; the caller + /// holds it for the duration and hands it back, so a drag is applied to + /// that rather than accumulated frame by frame — the rule + /// [`Self::drag_gradient_handle`] states, for the same reasons. + pub fn drag_spot( + &mut self, + id: &str, + role: crate::SpotRole, + origin: Option<&dr_pipeline::Spot>, + press: (f32, f32), + now: (f32, f32), + ) -> Option { + let (sw, sh) = self.demosaiced.size(); + let framing = *self.graph.framing(); + let aspect = sw.max(1) as f32 / sh.max(1) as f32; + + let start = match origin { + Some(spot) => spot.clone(), + None => self.graph.spots().get(id)?.clone(), + }; + + // The displacement in source coordinates. Affine, so a movement is a + // movement: the map may be run on the two endpoints and subtracted, + // which is what makes a drag on a rotated photograph move the repair in + // the direction the finger went. + let from = framing.source_at(press, sw, sh); + let to = framing.source_at(now, sw, sh); + let moved = (to.0 - from.0, to.1 - from.1); + + let spot = self.graph.spots_mut().get_mut(id)?; + match role { + crate::SpotRole::Destination => { + spot.set_centre((start.centre.0 + moved.0, start.centre.1 + moved.1)); + } + // In frame units, because that is what an offset is stored in — and + // the x half of a normalised displacement is short by the aspect. + crate::SpotRole::Source => { + spot.set_offset((start.offset.0 + moved.0 * aspect, start.offset.1 + moved.1)); + } + } + // Nothing recorded here: a drag delivers a pointer event a frame, and + // one history step apiece would make undo walk the gesture back pixel + // by pixel. Recorded once, on release. + Some(start) + } + + /// A repair's drag finished: one history step for the whole gesture. + pub fn commit_spot_drag(&mut self) { + self.history + .record(&self.graph, Edit::Action(labels::step::SPOT_MOVED)); + } + + /// Which repair the column is describing. + pub fn selected_spot(&self) -> Option<&dr_pipeline::Spot> { + let id = self.selected_spot.as_deref()?; + self.graph.spots().get(id) + } + + pub fn selected_spot_id(&self) -> Option<&str> { + self.selected_spot.as_deref() + } + + /// Choose a repair, or `None` to describe none. + /// + /// An id the graph no longer holds selects nothing rather than being kept: + /// the circle that offered it is stale by the time the press lands, and a + /// selection pointing at a deleted repair would leave the column describing + /// something that is not on the photograph. + pub fn select_spot(&mut self, id: Option<&str>) { + self.selected_spot = id + .filter(|id| self.graph.spots().get(id).is_some()) + .map(str::to_string); + } + + /// TRACES: FR-DEV-8 + /// Take a repair off the photograph, returning whether one went. + pub fn remove_spot(&mut self, id: &str) -> bool { + if self.graph.spots_mut().remove(id).is_none() { + return false; + } + if self.selected_spot.as_deref() == Some(id) { + self.selected_spot = None; + } + self.history + .record(&self.graph, Edit::Action(labels::step::SPOT_REMOVED)); + true + } + + /// TRACES: FR-DEV-8 + /// Change one of the selected repair's settings. + /// + /// Recorded as a named [`Edit::Action`] rather than under a parameter key: + /// a repair is not an operation and has no `OpId` to name or coalesce by, + /// so a slider drag over it records a step per movement unless the caller + /// debounces. `SliderRow` fires once per completed gesture, + /// which is what makes that acceptable here and is why this is the one + /// panel in the application built from that row rather than from a live + /// track. + pub fn set_selected_spot(&mut self, change: F) -> bool + where + F: FnOnce(&mut dr_pipeline::Spot), + { + let Some(id) = self.selected_spot.clone() else { + return false; + }; + let Some(spot) = self.graph.spots_mut().get_mut(&id) else { + return false; + }; + change(spot); + self.history + .record(&self.graph, Edit::Action(labels::step::SPOT)); + true + } + + /// How many repairs this photograph carries. + pub fn spot_count(&self) -> usize { + self.graph.spots().len() + } +} diff --git a/ui/dr-ui/src/develop/rows.rs b/ui/dr-ui/src/develop/rows.rs new file mode 100644 index 0000000..10a40c2 --- /dev/null +++ b/ui/dr-ui/src/develop/rows.rs @@ -0,0 +1,1236 @@ +//! Turning `EditGraph` capabilities into the flat `ParamRow` list the panel +//! draws, and the handful of session methods (`set_param`, `reset_*`) that +//! write back through the same row indices. +use dr_pipeline::{Edit, OpCapability, OpId, ParamId, ParamKind, Unit, WidgetKind}; +#[cfg(test)] +use dr_pipeline::{EditGraph, Presentation}; + +use crate::labels; +use crate::ParamRow; + +use super::curves::curve_row; +use super::session::DevelopSession; + +// The empty nested models, each a single shared identity. +// +// **`ModelRc` compares by identity, not by contents**, and `sync_rows` decides +// which controls to invalidate by comparing each freshly built row against the +// one on screen. A brand-new empty model per row per call therefore makes every +// row differ from *itself* on every parameter event, and the panel rewrites all +// of them. +// +// That is not merely wasteful — it breaks dragging. An operation with several +// parameters renders them through a repeater whose model is read off the +// group's head row; rewriting that row re-evaluates the repeater, rebuilding +// its items and destroying the `TouchArea` that holds the gesture. The slider +// takes the press, jumps once, then goes dead under the finger. Only +// multi-parameter operations show it, because a lone parameter has no inner +// repeater to rebuild — which is exactly how it hid: exposure and contrast drag +// perfectly while temperature and tint do not. +// +// Most rows carry neither points nor choices, so the empty case is the common +// one and it costs nothing to make it a constant. + +/// The empty points model, shared by every row that is not a curve. +pub(super) fn no_points() -> slint::ModelRc { + thread_local! { + static EMPTY: slint::ModelRc = + slint::ModelRc::new(slint::VecModel::from(Vec::::new())); + } + EMPTY.with(Clone::clone) +} + +/// The empty choices model, shared by every row that is not an enum. +pub(super) fn no_choices() -> slint::ModelRc { + thread_local! { + static EMPTY: slint::ModelRc = + slint::ModelRc::new(slint::VecModel::from(Vec::::new())); + } + EMPTY.with(Clone::clone) +} + +/// The choices model for one enum parameter, built once per variant list. +/// +/// Memoised for exactly the reason [`no_choices`] is shared: `ModelRc` compares +/// by *identity*, so building a fresh one each call makes the row differ from +/// itself on every parameter event. `sync_rows` would then replace the row — +/// destroying the elements built from it, including whichever `TouchArea` is +/// holding the current gesture — and the enum's own control would fight every +/// slider drag elsewhere in the panel. +/// +/// Curve rows solve the same problem the other way, by writing new values +/// through the existing model. That is not available here: a variant list is +/// fixed at compile time, so the model never needs updating and can simply be +/// the same one every time. +/// +/// Keyed on the labels rather than the slice's address, because they are +/// resolved through the UI's catalogue and two operations offering the same +/// choices should share one model. +fn choices_model(labels: &[slint::SharedString]) -> slint::ModelRc { + use std::cell::RefCell; + use std::collections::HashMap; + + thread_local! { + static CACHE: RefCell>> = + RefCell::new(HashMap::new()); + } + let key = labels.join("\u{1f}"); + CACHE.with(|cache| { + cache + .borrow_mut() + .entry(key) + .or_insert_with(|| slint::ModelRc::new(slint::VecModel::from(labels.to_vec()))) + .clone() + }) +} + +/// Whether this frontend has an implementation of `widget` **anywhere**. +/// +/// "Anywhere" is doing real work: a widget may be drawn in the panel, as the +/// tone curve is, or hosted on the canvas, as the crop is. Both count as +/// implemented, and the difference is settled afterwards by +/// [`WidgetKind::is_on_canvas`] rather than by two separate lists that could +/// disagree about the same kind. +/// +/// A kind answering `false` here is not an error — the operation's parameters +/// are ordinary scalars, so it falls back to sliders and stays fully editable +/// (ARCH §4.3a). +pub(crate) fn supported(widget: WidgetKind) -> bool { + match widget { + // Drawn in the panel. + WidgetKind::ToneCurve => true, + // Hosted on the canvas: the overlay is drawn over the photograph and + // the panel contributes `ComposePanel`, the affordance that turns it + // on. + WidgetKind::CropOverlay => true, + // TRACES: FR-DEV-3 + // Hosted on the canvas too — a click on the photograph — with the + // affordance that arms it in the group's own heading. See + // `samples_the_canvas` for why this one leaves its sliders standing + // where the crop takes them away. + WidgetKind::WhitePoint => true, + // Not implemented. Listed rather than caught by a wildcard so the next + // kind added to the core surfaces here as a compile error. + WidgetKind::ColourWheel | WidgetKind::GradientHandle | WidgetKind::BrushMask => false, + } +} + +/// TRACES: FR-DEV-3a | FR-UI-7 +/// Whether an on-canvas widget *reads* the photograph rather than replacing +/// its parameters with handles. +/// +/// **This is the distinction that stopped the picker eating its own sliders.** +/// [`WidgetKind::is_on_canvas`] says where a widget is manipulated, and the +/// panel had been treating that as also meaning "and so the panel draws +/// nothing for it". For a crop that is right: four edge fractions and an angle +/// are not controls anybody drags in a list, and the whole reason the crop is +/// on the photograph is that they are unusable anywhere else. +/// +/// An eyedropper is the other thing. It *writes* temperature and tint — they +/// remain exactly the controls a photographer reaches for afterwards, because +/// a sampled neutral is a starting point and warming a portrait past it is the +/// next move, not a mistake. Taking the sliders away to make room for the +/// picker would be trading a control for a control. +/// +/// So the panel draws the group as usual and puts the affordance that arms the +/// canvas in its heading. FR-DEV-3's "temperature/tint, **and** picker" is one +/// word doing a lot of work, and this is the word. +pub(super) fn samples_the_canvas(widget: WidgetKind) -> bool { + match widget { + WidgetKind::WhitePoint => true, + // Dragged rather than sampled: the parameters *are* the handles. + WidgetKind::CropOverlay | WidgetKind::GradientHandle | WidgetKind::BrushMask => false, + // Not on the canvas at all, so nothing asks. Listed rather than + // wildcarded for the reason `supported` lists its own. + WidgetKind::ToneCurve | WidgetKind::ColourWheel => false, + } +} + +/// The panel model for a set of capabilities. +/// +/// Free-standing rather than a method, and that is the point: it needs no GPU, +/// no decoded image and no session, so the whole descriptor-to-panel path can +/// be exercised against a hand-built capability list. That is what the +/// FR-DEV-3c acceptance test asks for — an operation the frontend has never +/// heard of appearing in a generated panel — and it cannot be asserted at all +/// if generating a row requires a device. +/// +/// `#[cfg(test)]` since the panel began passing the selected curve down: the +/// session always has one to pass, and a wrapper that quietly picked the first +/// would be a second answer to a question the session already answers. +#[cfg(test)] +pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec { + rows_filtered(caps, |_| true, 0) +} + +/// The panel model for the capabilities `keep` accepts. +/// +/// **`op_index` counts over every capability, not over the kept ones.** It is +/// how a row routes back to the core, so filtering must not renumber it — a +/// row that survived a filter has to still name the operation it came from. +/// `group_head` is the opposite: a position within the *emitted* rows, because +/// the panel walks back to it through the model it was given. +/// +/// Getting that backwards is how a slider ends up driving a different +/// operation, which is the kind of fault that looks like a rendering bug. +/// +/// `curve_channel` is which subject a multi-subject widget is showing — the +/// tone curve's four curves are one plot with a selector over it. It is passed +/// in rather than read from anywhere because this function is deliberately +/// free-standing: the descriptor-to-panel path has to be exercisable against a +/// hand-built capability list with no session behind it. +pub(crate) fn rows_filtered( + caps: &[OpCapability], + keep: impl Fn(&OpCapability) -> bool, + curve_channel: usize, +) -> Vec { + let mut rows = Vec::new(); + for (op_index, op) in caps.iter().enumerate() { + if !keep(op) { + continue; + } + // Where this operation's rows begin. The panel groups by walking + // back to it, so it has to be taken before any row is pushed. + let group_head = rows.len(); + // TRACES: FR-DEV-3 + // Whether this group's heading carries the affordance that arms an + // on-canvas sampler. Set below, from what the operation asked for and + // nothing else — the panel never learns which operation it is. + let mut group_samples = false; + // An operation may ask for one widget spanning several + // parameters. Honouring it is optional — dropping this block + // renders the same parameters as ordinary sliders, and the edit + // still works — which is exactly why the hint is a hint. + if let Some(presentation) = &op.presentation { + // **The widget registry, and the only one.** + // + // `choose` walks the operation's preference list and hands back + // the first entry this frontend implements (ARCH §4.3a). A kind + // it does not implement falls through to sliders — the designed + // behaviour, not a gap, since every parameter is an individually + // addressable scalar. + if let Some(widget) = presentation.choose(supported) { + // **Yielded to the canvas, and this is what replaced naming + // framing.** + // + // This loop used to open with `if op.id == framing::ID { continue }` + // and a paragraph explaining that a crop is dragged on the + // photograph rather than typed into four boxes. All of that is + // true and none of it was this file's to know: it is a fact + // about the operation, and it now arrives as one. Any stage + // preferring an on-canvas widget is skipped here on the same + // terms, with nothing named. + // + // Skipped rather than rendered as an affordance row, because + // the affordance is `ComposePanel` — a bespoke control for a + // known stage, which is a thing the interface is entitled to + // build (ARCH §4.3a draws the line at the *generated* panel + // naming stages, not at the interface having hand-made + // widgets). + // + // TRACES: FR-DEV-3 + // **Unless the canvas is *reading* rather than driving.** An + // eyedropper writes temperature and tint and leaves them as + // the controls they were, so its group is drawn in full and + // only the affordance moves to the heading. See + // `samples_the_canvas` for the whole of that argument. + group_samples = samples_the_canvas(widget); + if widget.is_on_canvas() && !group_samples { + continue; + } + + // The `match` is exhaustive on purpose. Adding a `WidgetKind` + // to the core stops this compiling until someone has decided, + // here, whether the panel draws it. + let row = match widget { + WidgetKind::ToneCurve => { + curve_row(op_index, group_head, op, presentation, curve_channel) + } + // Canvas-hosted kinds returned above, except a + // sampler, which falls through to its own sliders; the + // rest are not implemented and reached sliders via + // `choose`. + WidgetKind::ColourWheel + | WidgetKind::CropOverlay + | WidgetKind::GradientHandle + | WidgetKind::BrushMask + | WidgetKind::WhitePoint => None, + }; + if let Some(row) = row { + rows.push(row); + continue; + } + } + } + + // Whether anything in this operation has been touched, aggregated + // before the rows are built so every row of the group can carry + // the same answer — the panel's heading is one of them and cannot + // see the others. + // + // Derived here rather than asked of the core: a group is a + // composition this side invented, so whether one is modified is + // this side's question to answer (ARCH §4.3a). + let group_modified = op.params.iter().any(|p| p.value != p.default); + let group_len = op.params.len() as i32; + + // The aspect the previous row belonged to, so a run can be told + // from its continuation. Reset per operation: two operations that + // happened to facet on the same key are still two groups. + let mut previous_aspect: Option<&str> = None; + + for param_index in presentation_order(&op.params) { + let p = &op.params[param_index]; + // Empty for every kind but `Enum`, which is what the panel + // keys on to build a segmented control rather than a slider. + let mut choices: Vec = Vec::new(); + let (kind, min, max, precision, unit) = match &p.kind { + ParamKind::Scalar { + min, + max, + unit, + precision, + .. + } => ( + "scalar", + *min, + *max, + i32::from(*precision), + unit_suffix(*unit), + ), + ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""), + // The value is a variant index, so the range is the list's + // own bounds and the precision is whole numbers. Labels are + // resolved here, against this crate's catalogue, because + // the core deals in localisation keys only (NFR-A11Y-1). + ParamKind::Enum { variants } => { + choices = variants + .iter() + .map(|v| labels::resolve(v.0).into()) + .collect(); + ("enum", 0.0, variants.len().saturating_sub(1) as f32, 0, "") + } + }; + + // A faceted parameter is named by its *subject* — the band — + // because its aspect is already written above the run it sits + // in. Unfaceted parameters keep their own label, which is + // every operation but the mixer. + // + // Except when the parameter is the operation's only one. The panel + // draws no heading over a group of one, on the argument that a + // lone control names itself — and that holds only while the + // parameter is named after what it does. Three operations declare + // a single parameter called `amount`, which is the name + // `ops/README.md` tells an author to reach for first, and they + // arrived in the panel as three consecutive sliders all labelled + // "Amount" with nothing to tell them apart. + // + // So a lone parameter is titled by its operation. That is the name + // the missing heading would have carried, and for the operations + // whose one parameter already shares the operation's name it reads + // exactly as it did before. + let param_label = match &p.facet { + Some(f) => labels::resolve(f.subject.0), + None if op.params.len() == 1 => labels::resolve(op.label.0), + None => labels::resolve(p.label.0), + }; + let aspect = p.facet.as_ref().map(|f| f.aspect.0); + let starts_facet = aspect.is_some() && aspect != previous_aspect; + previous_aspect = aspect; + + rows.push(ParamRow { + op_index: op_index as i32, + param_index: param_index as i32, + op_label: labels::resolve(op.label.0).into(), + param_label: param_label.into(), + facet_label: aspect.map(labels::resolve).unwrap_or_default().into(), + starts_facet, + // -1 rather than an `Option`, which a Slint struct cannot + // carry: 0° is red, so no value in range can stand for + // "no swatch". + swatch_hue: p.facet.as_ref().and_then(|f| f.subject_hue).unwrap_or(-1.0), + group_head: group_head as i32, + group_len, + group_modified, + group_samples, + kind: kind.into(), + value: p.value, + default_value: p.default, + minimum: min, + maximum: max, + precision, + unit: unit.into(), + // Only curve rows carry points. + points: no_points(), + // The shared empty model unless this row really has choices — + // see `no_choices` for why the identity matters. + choices: if choices.is_empty() { + no_choices() + } else { + choices_model(&choices) + }, + }); + } + } + rows +} + +/// The order an operation's parameters are shown in. +/// +/// Declaration order, unless the operation facets them — in which case +/// parameters sharing an aspect are brought together, so the panel names +/// each run once instead of repeating "Hue / Saturation / Luminance" +/// twelve times over. The colour mixer declares band by band, which is the +/// order the shader wants; a photographer works channel by channel. +/// +/// **This is presentation, and so it lives here** (ARCH §4.3a). The core +/// says which aspect a parameter belongs to; deciding that an aspect is +/// worth stacking rows by is the panel's composition to make, exactly as +/// grouping by operation is. Routing is unaffected — `param_index` stays +/// the position in the capability list however the rows are stacked. +/// +/// A stable sort by the aspect's first appearance, so an operation with no +/// facets comes back untouched, and one that mixes plain parameters with +/// faceted ones keeps the plain ones first and in order. +pub(super) fn presentation_order(params: &[dr_pipeline::ParamCapability]) -> Vec { + let mut aspects: Vec<&str> = Vec::new(); + let rank: Vec = params + .iter() + .map(|p| match &p.facet { + None => 0, + Some(f) => { + let at = aspects.iter().position(|a| *a == f.aspect.0); + // First appearance defines the run's place, so the panel's + // sections come out in the order the operation introduced + // them rather than alphabetically. + 1 + at.unwrap_or_else(|| { + aspects.push(f.aspect.0); + aspects.len() - 1 + }) + } + }) + .collect(); + + let mut order: Vec = (0..params.len()).collect(); + order.sort_by_key(|i| rank[*i]); + order +} + +/// Suffix shown after a value. Comes from the descriptor's declared unit, so +/// this function needs no knowledge of which parameter it is formatting. +pub(super) fn unit_suffix(unit: Unit) -> &'static str { + match unit { + Unit::None => "", + Unit::Stops => " EV", + Unit::Kelvin => " K", + Unit::Percent => "%", + } +} + +impl DevelopSession { + /// Return every parameter of one operation to its default. + /// + /// What both a section's reset and a curve's reset do — a curve is one + /// widget spanning all of its operation's parameters, so "reset this + /// curve" and "reset this operation" were always the same action. Nothing + /// here is curve-shaped; it walks whatever parameters the operation + /// declares. + pub fn reset_op(&mut self, op_index: i32) { + let caps = self.scoped_capabilities(); + let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else { + return; + }; + if !self.active_masks.is_empty() { + let params: Vec<_> = cap.params.iter().map(|p| (p.id, p.default)).collect(); + let id = cap.id.0; + for layer in self.active_layers_mut() { + for &(param, default) in ¶ms { + layer.set_param(id, param, default); + } + } + self.history + .record(&self.graph, Edit::Action(labels::step::RESET_OP)); + return; + } + for p in &cap.params { + self.graph.set_param(cap.id, p.id, p.default); + } + // One step, though it moved every parameter the operation has: the + // user pressed one button. + self.history + .record(&self.graph, Edit::Action(labels::step::RESET_OP)); + } + + /// Reset a curve, which is to reset its operation. + /// + /// Kept as its own name because the call site is a curve widget's own + /// double-click, and reading `reset_curve` there says why it resets ten + /// parameters at once rather than the one that was clicked. + pub fn reset_curve(&mut self, op_index: i32) { + self.reset_op(op_index); + } + + /// Apply a change from the interface. + /// + /// Indices are positions in [`Self::rows`]; the mapping back to ids stays + /// on this side of the boundary. + pub fn set_param(&mut self, op_index: i32, param_index: i32, value: f32) { + let Some((op, param)) = self.lookup(op_index, param_index) else { + log::warn!("control at ({op_index}, {param_index}) has no parameter"); + return; + }; + if !self.active_masks.is_empty() { + // Every selected layer is set to the same absolute value the + // slider now shows, not offset by however far each one already + // was from it — the slider has one position, and "apply this + // reading to all of them" is the reading a photographer gets + // from watching it move. + for layer in self.active_layers_mut() { + layer.set_param(op.0, param, value); + } + // Coalesced the same way a global drag is: a slider dragged across + // masked layers is still one gesture and must undo as one. + let edit = Edit::for_param(&self.graph, op, param); + self.history.record(&self.graph, edit); + return; + } + + self.graph.set_param(op, param, value); + let edit = Edit::for_param(&self.graph, op, param); + self.history.record(&self.graph, edit); + } + + /// Return one parameter to its default. + pub fn reset_param(&mut self, op_index: i32, param_index: i32) { + let Some((op, param)) = self.lookup(op_index, param_index) else { + return; + }; + let default = self + .graph + .capabilities() + .iter() + .find(|c| c.id == op) + .and_then(|c| c.params.iter().find(|p| p.id == param)) + .map(|p| p.default) + .unwrap_or(0.0); + self.graph.set_param(op, param, default); + self.history + .record(&self.graph, Edit::Action(labels::step::RESET_PARAM)); + } + + pub fn reset_all(&mut self) { + self.graph.reset(); + self.history + .record(&self.graph, Edit::Action(labels::step::RESET_ALL)); + } + + pub(super) fn lookup(&self, op_index: i32, param_index: i32) -> Option<(OpId, ParamId)> { + // Rows are emitted in capability order, so the flat index is the sum + // of preceding parameter counts. Taken from whichever scope `rows` + // last described — the indices the interface is holding are positions + // in *that* list, and reading the global chain while a layer is + // selected would map a slider onto a different operation. + // The *unfiltered* scoped list, because `op_index` counts over every + // capability — see `rows_filtered`. Indexing a filtered list here is + // how a slider would drive the wrong operation once a tab is chosen. + let caps = self.scoped_capabilities(); + let op = caps.get(usize::try_from(op_index).ok()?)?; + let param = op.params.get(usize::try_from(param_index).ok()?)?; + Some((op.id, param.id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_capability_becomes_exactly_one_row() { + // The UI shows what the pipeline offers — no more, and nothing + // dropped. Asserted against the chain rather than a literal count, + // so operations can be added without editing this, and so the test + // actually checks the correspondence rather than restating a number. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + let expected: usize = caps.iter().map(|c| c.params.len()).sum(); + + assert!(expected > 0, "the chain must expose some parameters"); + // Every (operation, parameter) pair must be reachable as a distinct + // row index; a collision would route two sliders to one parameter. + let mut seen = std::collections::HashSet::new(); + for (oi, cap) in caps.iter().enumerate() { + for (pi, _) in cap.params.iter().enumerate() { + assert!(seen.insert((oi, pi)), "duplicate row index"); + } + } + assert_eq!(seen.len(), expected); + } + + #[test] + fn each_operation_becomes_exactly_one_group() { + // The panel draws one section per group, and derives the boundary + // from `group_head` rather than from a flag the core supplies. Two + // heads for one operation would draw its heading twice; none would + // swallow the operation into the section above it. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + + // A row heads its group exactly when its own index equals its + // `group_head` — the same test `adjust.slint` makes. + let mut heads = 0; + for (i, row) in rows_of(&caps).iter().enumerate() { + if row.0 == i { + heads += 1; + } + } + // Every operation but framing, which has its own panel. + let generated = caps + .iter() + .filter(|c| c.id != dr_pipeline::framing::ID) + .count(); + assert_eq!(heads, generated); + } + + #[test] + fn regenerating_the_rows_leaves_unchanged_ones_equal() { + // **This is a dragging test wearing a data disguise.** + // + // `sync_rows` rewrites exactly the rows that compare unequal, and a + // rewritten row re-evaluates the repeater that a multi-parameter + // operation renders its parameters through — which rebuilds the items + // and destroys the `TouchArea` mid-gesture. So a row that differs from + // itself between two identical calls is a slider that takes the press, + // jumps once and then dies under the finger. + // + // It is asserted here rather than left to the eye because the failure + // is invisible in a still: every value is right, the panel looks + // perfect, and only a live drag on a *grouped* parameter shows it. + // `ModelRc` compares by identity, so any new model-valued field + // reintroduces this the moment it is built fresh per call. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + + let first = rows_from(&caps); + let second = rows_from(&caps); + assert_eq!(first.len(), second.len()); + + for (i, (a, b)) in first.iter().zip(second.iter()).enumerate() { + // A curve row is the one legitimate exception: its `points` model + // carries live coordinates, so it genuinely is rebuilt each call + // and `sync_rows` writes the values through the existing model + // instead of swapping it. Every other row must be stable here, at + // the source, rather than relying on a caller to repair it. + if a.kind == "curve" { + continue; + } + assert!( + a == b, + "row {i} ({}) differs from itself across two identical builds, \ + so every parameter event would rewrite it and break dragging", + a.param_label + ); + } + } + + #[test] + fn a_grouped_parameter_survives_a_neighbours_change() { + // The reported bug, at the level it actually occurred. Moving + // temperature flips `group_modified` on *both* of white balance's + // rows — that much is correct and intended. What must not happen is + // the untouched rows of *other* operations also coming back unequal, + // because rewriting a group's head row is what rebuilds the repeater + // holding the live drag. + let mut graph = EditGraph::default_chain(); + let before = rows_from(&graph.capabilities()); + + // Move the first parameter of the first multi-parameter operation, + // named by shape rather than by id so this keeps testing the property + // when the chain changes. + let caps = graph.capabilities(); + let group = caps + .iter() + .find(|c| c.params.len() > 1 && c.presentation.is_none()) + .expect("some operation has several plain parameters"); + let target = &group.params[0]; + graph.set_param(group.id, target.id, target.default + 1.0); + + let after = rows_from(&graph.capabilities()); + assert_eq!(before.len(), after.len()); + + // Curve rows excluded for the reason given in the test above: their + // points model is rebuilt by design and repaired in `sync_rows`. + let changed: Vec<&str> = before + .iter() + .zip(after.iter()) + .filter(|(a, b)| a != b && a.kind != "curve") + .map(|(a, _)| a.param_label.as_str()) + .collect(); + + // Its own group, and nothing beyond it. + assert_eq!( + changed.len(), + group.params.len(), + "moving one parameter should dirty only its own group's rows, \ + but these came back changed: {changed:?}" + ); + } + + /// TRACES: FR-DEV-3 | FR-DEV-3a + /// A canvas *sampler* adds an affordance; it does not take the sliders. + /// + /// The distinction the panel had been missing. `is_on_canvas` was read as + /// "and so the panel draws nothing", which is right for a crop and wrong + /// for an eyedropper — FR-DEV-3 asks for "temperature/tint, **and** + /// picker", and a picker that ate the two sliders would have traded one + /// control for another. Asserted against the chain rather than a literal, + /// so it keeps testing the property when the declaration moves. + #[test] + fn a_sampled_operation_keeps_the_sliders_it_writes() { + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + + let (index, cap) = caps + .iter() + .enumerate() + .find(|(_, c)| { + c.presentation + .as_ref() + .is_some_and(|p| p.widgets.contains(&WidgetKind::WhitePoint)) + }) + .expect("some operation asks to be driven by a pixel"); + + let rows = rows_from(&caps); + let mine: Vec<_> = rows.iter().filter(|r| r.op_index == index as i32).collect(); + + assert_eq!( + mine.len(), + cap.params.len(), + "every parameter of a sampled operation still has its own control" + ); + assert!( + mine.iter().all(|r| r.group_samples), + "and the group's heading carries the picker" + ); + assert!( + rows.iter() + .filter(|r| r.op_index != index as i32) + .all(|r| !r.group_samples), + "no other group claims one" + ); + } + + #[test] + fn framing_is_not_generated_as_sliders() { + // `ComposePanel` presents crop, rotation, flips and straightening as + // the gestures they are. If the generic path emitted them too the + // sidebar would carry both — including four "Crop Left/Top/Width/ + // Height" sliders no one can compose a photograph with. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + + let framing = caps + .iter() + .position(|c| c.id == dr_pipeline::framing::ID) + .expect("the chain must still expose framing — the panel reads it"); + assert!(!caps[framing].params.is_empty()); + + // Checked against the real generator, and by *routing* rather than by + // counting: a row carries the capability index it writes back to, so + // "no row belongs to framing" is the property directly, and it cannot + // be satisfied accidentally by two miscounts cancelling out. + let rows = rows_from(&caps); + assert!( + rows.iter().all(|r| r.op_index as usize != framing), + "framing parameters leaked into the generated panel" + ); + // Every other operation still arrives, so the skip is specific rather + // than the panel having quietly stopped generating. + assert!(rows.len() > caps.len() - 1); + } + + #[test] + fn a_stage_is_yielded_to_the_canvas_by_what_it_declares_not_by_its_name() { + // The property that replaced `if op.id == framing::ID`. An invented + // stage preferring an on-canvas widget must be skipped on exactly the + // same terms — if this needs a name added anywhere to pass, the + // special case has grown back. + use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand}; + + let param = |id: &'static str| ParamCapability { + id: ParamId(id), + label: LocalizedKey("param.invented"), + kind: ParamKind::Scalar { + min: 0.0, + max: 1.0, + scale: dr_pipeline::Scale::Linear, + unit: Unit::None, + precision: 2, + }, + default: 0.0, + value: 0.0, + facet: None, + }; + + let on_canvas = OpCapability { + id: OpId("invented_mask"), + label: LocalizedKey("op.invented_mask"), + active: false, + presentation: Some(Presentation { + // Prefers a gradient handle; this frontend has none, so it + // falls back to the next entry, which the canvas does host. + widgets: vec![WidgetKind::GradientHandle, WidgetKind::CropOverlay], + demand: WidgetDemand { + two_dimensional: true, + precise_pointing: false, + }, + params: vec![ParamId("a"), ParamId("b")], + }), + params: vec![param("a"), param("b")], + attributes: vec![dr_pipeline::Attribute::Tone], + }; + + assert!(rows_from(&[on_canvas]).is_empty()); + } + + #[test] + fn a_group_spans_exactly_its_operations_rows() { + // `group_len` is how many rows the section reaches forward over. Too + // few silently drops controls off the bottom of a section; too many + // reads past the model and renders a neighbouring operation's + // parameters under the wrong heading. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + let rows = rows_of(&caps); + + for (i, row) in rows.iter().enumerate() { + let (head, len) = *row; + assert!(head <= i, "row {i} claims a head after itself"); + assert!( + head + len <= rows.len(), + "group at {head} reaches past the model" + ); + // Every row the group spans must agree it belongs to that group. + for (offset, spanned) in rows[head..head + len].iter().enumerate() { + let span = head + offset; + assert_eq!(spanned.0, head, "row {span} disagrees about its group"); + } + } + } + + #[test] + fn a_group_is_modified_when_any_of_its_parameters_is() { + // The dot on a collapsed section is the only thing saying an edit is + // hidden inside it, and it is derived here rather than asked of the + // core (ARCH §4.3a). + let mut graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + // A fresh chain is at its defaults, so nothing is modified. + assert!( + caps.iter() + .all(|c| c.params.iter().all(|p| p.value == p.default)), + "a fresh chain must start neutral" + ); + + // Move one parameter of one operation off its default; only that + // operation's group may light up. + let (op_id, param_id, default) = caps + .iter() + .find_map(|c| { + c.params + .iter() + .find(|p| matches!(p.kind, ParamKind::Scalar { .. })) + .map(|p| (c.id, p.id, p.default)) + }) + .expect("the chain has a scalar parameter"); + graph.set_param(op_id, param_id, default + 1.0); + + let caps = graph.capabilities(); + let modified: Vec = caps + .iter() + .map(|c| c.params.iter().any(|p| p.value != p.default)) + .collect(); + assert_eq!( + modified.iter().filter(|m| **m).count(), + 1, + "one edit must mark exactly one group" + ); + + // And it goes out again when the value returns. + graph.set_param(op_id, param_id, default); + assert!( + graph + .capabilities() + .iter() + .all(|c| c.params.iter().all(|p| p.value == p.default)), + "returning a value to its default must clear the group" + ); + } + + /// `(group_head, group_len)` per row, flattened as + /// [`DevelopSession::rows`] flattens — without needing a GPU to build a + /// session. + /// + /// A widget hint only collapses an operation to one row when it is + /// *honoured*; `rows` falls back to sliders otherwise, and mirroring that + /// here is what keeps the test honest when a hint stops applying. + /// TRACES: FR-DEV-3a + /// A declared switch reaches the panel as a switch. + /// + /// `ParamKind::Bool` was in the core's closed enum, was mapped to the row + /// kind `"bool"` here, and had no control behind it in `adjust.slint` — + /// which meant a parameter declaring itself a switch was flattened into a + /// row that drew nothing at all. Nothing shipped had one until the lens + /// profile did, so the gap cost nothing and was invisible. + /// + /// This asserts the Rust half; the Slint half is the `if kind == "bool"` + /// branch in the control registry, which this cannot reach. + #[test] + fn a_switch_becomes_a_switch_row() { + use dr_pipeline::{LocalizedKey, ParamCapability}; + + let switched = OpCapability { + id: OpId("invented_switch"), + label: LocalizedKey("op.invented_switch"), + active: true, + presentation: None, + params: vec![ParamCapability { + id: ParamId("engaged"), + label: LocalizedKey("param.invented_switch.engaged"), + kind: ParamKind::Bool, + // On by default, which is the shape a correction the file + // itself asked for takes: off is the edit. + default: 1.0, + value: 0.0, + facet: None, + }], + attributes: vec![dr_pipeline::Attribute::Optics], + }; + + let rows = rows_from(&[switched]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].kind, "bool"); + assert_eq!(rows[0].value, 0.0); + assert_eq!(rows[0].default_value, 1.0); + // A lone parameter is titled by its operation, so the box carries the + // name the withheld heading would have. + assert_eq!( + rows[0].param_label, + labels::resolve("op.invented_switch").as_str() + ); + assert!( + rows[0].group_modified, + "a switch turned off differs from its default like any other \ + parameter, and the group's marker has to say so" + ); + } + + /// TRACES: FR-DEV-3c + /// An operation this file has never heard of, appearing in the panel. + /// + /// The acceptance test requirements.md names for FR-DEV-3c: "a test + /// operation added to the registry appears in a generated panel with no + /// frontend change". Built as a capability rather than a real node so it + /// costs the pipeline nothing — what is being asserted is the mapping from + /// descriptor to control, and that mapping does not care whether a shader + /// exists behind it. + #[test] + fn an_operation_the_frontend_has_never_heard_of_gets_controls() { + use dr_pipeline::{LocalizedKey, ParamCapability}; + + let invented = OpCapability { + id: OpId("invented"), + label: LocalizedKey("op.invented"), + active: false, + presentation: None, + params: vec![ + ParamCapability { + id: ParamId("strength"), + label: LocalizedKey("param.invented.strength"), + kind: ParamKind::Scalar { + min: -100.0, + max: 100.0, + scale: dr_pipeline::Scale::Linear, + unit: Unit::Percent, + precision: 0, + }, + default: 0.0, + value: 25.0, + facet: None, + }, + ParamCapability { + id: ParamId("method"), + label: LocalizedKey("param.invented.method"), + kind: ParamKind::Enum { + variants: vec![ + LocalizedKey("param.invented.method.fast"), + LocalizedKey("param.invented.method.exact"), + ], + }, + default: 0.0, + value: 1.0, + facet: None, + }, + ], + attributes: vec![dr_pipeline::Attribute::Tone], + }; + + let rows = rows_from(&[invented]); + assert_eq!(rows.len(), 2, "each parameter should become one row"); + + // The scalar becomes a slider carrying its declared range and unit. + assert_eq!(rows[0].kind, "scalar"); + assert_eq!(rows[0].minimum, -100.0); + assert_eq!(rows[0].maximum, 100.0); + assert_eq!(rows[0].value, 25.0); + + // The enum becomes a choice, with its range spanning the variant + // indices and the variant names resolved for drawing. Nothing in this + // file names the operation or either parameter to make that happen. + assert_eq!(rows[1].kind, "enum"); + assert_eq!(rows[1].minimum, 0.0); + assert_eq!(rows[1].maximum, 1.0); + assert_eq!(rows[1].precision, 0); + assert_eq!(slint::Model::row_count(&rows[1].choices), 2); + // The value is the selected index, which is what the segmented control + // reads — an enum needs no separate selection field. + assert_eq!(rows[1].value, 1.0); + } + + #[test] + fn an_unimplemented_widget_falls_back_to_sliders_rather_than_vanishing() { + // ARCH §4.3a: falling off the end of the preference list is not an + // error. An operation asking only for a widget this frontend does not + // draw must still yield one control per parameter, or declaring a + // preference would be a way to make an edit unreachable. + use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand}; + + let wheel = OpCapability { + id: OpId("grading"), + label: LocalizedKey("op.grading"), + active: false, + presentation: Some(Presentation { + widgets: vec![WidgetKind::ColourWheel], + demand: WidgetDemand { + two_dimensional: true, + precise_pointing: false, + }, + params: vec![ParamId("hue"), ParamId("strength")], + }), + params: vec![ + ParamCapability { + id: ParamId("hue"), + label: LocalizedKey("param.grading.hue"), + kind: ParamKind::Scalar { + min: 0.0, + max: 360.0, + scale: dr_pipeline::Scale::Linear, + unit: Unit::None, + precision: 0, + }, + default: 0.0, + value: 0.0, + facet: None, + }, + ParamCapability { + id: ParamId("strength"), + label: LocalizedKey("param.grading.strength"), + kind: ParamKind::Scalar { + min: 0.0, + max: 1.0, + scale: dr_pipeline::Scale::Linear, + unit: Unit::None, + precision: 2, + }, + default: 0.0, + value: 0.0, + facet: None, + }, + ], + attributes: vec![dr_pipeline::Attribute::Tone], + }; + + assert!(!supported(WidgetKind::ColourWheel), "precondition"); + let rows = rows_from(&[wheel]); + assert_eq!(rows.len(), 2, "both parameters must remain reachable"); + assert!(rows.iter().all(|r| r.kind == "scalar")); + } + + /// Each generated row's `(group_head, group_len)`. + /// + /// Taken from the real generator rather than re-derived. This used to be a + /// hand-written simulation of `rows_from` — it walked the capabilities and + /// reproduced the grouping rules, including a copy of the framing skip — + /// which meant the tests below asserted against a second implementation + /// that had to be kept in step with the first by hand. It was not: giving + /// framing a presentation changed the real panel and the simulation + /// disagreed, which is how a passing test suite would have hidden the + /// change entirely. + fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> { + rows_from(caps) + .iter() + .map(|r| (r.group_head as usize, r.group_len as usize)) + .collect() + } + + #[test] + fn an_operation_without_facets_keeps_its_declared_order() { + // Every operation but the mixer. Reordering one of these would move + // Highlights below Shadows for no reason anybody could see in the + // code, so the stable sort has to be a no-op when nothing is faceted. + let graph = EditGraph::default_chain(); + for cap in graph.capabilities() { + if cap.params.iter().any(|p| p.facet.is_some()) { + continue; + } + let order = presentation_order(&cap.params); + assert_eq!( + order, + (0..cap.params.len()).collect::>(), + "{} was reordered", + cap.id + ); + } + } + + #[test] + fn faceted_parameters_are_stacked_one_run_per_aspect() { + // The panel names a run once and then draws its rows. That only works + // if a run is *contiguous*: the mixer declares band by band — red hue, + // red sat, red lum, orange hue — so shown in declaration order every + // single row would begin a new run, and the panel would draw + // thirty-six headings over thirty-six sliders. + let graph = EditGraph::default_chain(); + let cap = graph + .capabilities() + .into_iter() + .find(|c| c.params.iter().any(|p| p.facet.is_some())) + .expect("the chain has a faceted operation"); + + let mut seen: Vec<&str> = Vec::new(); + let mut previous: Option<&str> = None; + for i in presentation_order(&cap.params) { + let aspect = cap.params[i] + .facet + .as_ref() + .expect("this operation facets every parameter") + .aspect + .0; + if previous != Some(aspect) { + assert!( + !seen.contains(&aspect), + "{aspect} is split into two runs — a heading would be \ + drawn over each half" + ); + seen.push(aspect); + previous = Some(aspect); + } + } + assert!(seen.len() > 1, "the fixture must have several aspects"); + } + + #[test] + fn reordering_rows_does_not_move_where_a_change_is_routed() { + // The rows are stacked for reading; `param_index` still addresses the + // capability list. Were the two confused, dragging a band's Hue would + // silently write to whichever parameter happened to sit at that + // position — an edit landing on the wrong control, which reads as the + // renderer being broken rather than the panel. + let graph = EditGraph::default_chain(); + let cap = graph + .capabilities() + .into_iter() + .find(|c| c.params.iter().any(|p| p.facet.is_some())) + .expect("the chain has a faceted operation"); + + let mut order = presentation_order(&cap.params); + order.sort_unstable(); + assert_eq!( + order, + (0..cap.params.len()).collect::>(), + "the order must be a permutation: every parameter reachable from \ + exactly one row, and every row addressing a parameter that exists" + ); + } + + #[test] + fn every_faceted_parameter_resolves_to_a_band_name() { + // The bug this closes: `labels.rs` had no `param.mixer.*` entries, so + // all thirty-six keys fell through to a derived label that yields the + // bare channel name — twelve rows reading "Hue" with nothing saying + // which band. A row identified only by a swatch depends on this + // resolving, since the name is what a screen reader speaks and what + // anyone who cannot separate two squares by eye has to go on. + let graph = EditGraph::default_chain(); + for cap in graph.capabilities() { + for p in &cap.params { + let Some(facet) = &p.facet else { continue }; + let subject = labels::resolve(facet.subject.0); + let aspect = labels::resolve(facet.aspect.0); + assert!(!subject.is_empty(), "{} has no subject name", p.id); + assert!(!aspect.is_empty(), "{} has no aspect name", p.id); + // Not the channel name repeated: that is exactly the failure + // the catalogue entries were added to fix. + assert_ne!(subject, aspect, "{} is named after its channel", p.id); + } + } + } + + #[test] + fn unit_suffixes_come_from_the_descriptor() { + assert_eq!(unit_suffix(Unit::Stops), " EV"); + assert_eq!(unit_suffix(Unit::None), ""); + } + + #[test] + fn a_lone_parameter_is_named_after_its_operation() { + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + let rows = rows_filtered(&caps, |_| true, 0); + + for (i, cap) in caps.iter().enumerate() { + if cap.params.len() != 1 || cap.presentation.is_some() { + continue; + } + let Some(row) = rows.iter().find(|r| r.op_index as usize == i) else { + continue; + }; + assert_eq!( + row.param_label, + labels::resolve(cap.label.0).as_str(), + "a lone parameter must carry its operation's name, or the \ + heading the panel withheld takes the name with it" + ); + } + + // And the specific collision that started this: no two rows drawn + // without a heading may read the same. + let bare: Vec<_> = rows + .iter() + .filter(|r| r.group_len == 1) + .map(|r| r.param_label.to_string()) + .collect(); + let mut unique = bare.clone(); + unique.sort(); + unique.dedup(); + assert_eq!( + bare.len(), + unique.len(), + "two headingless rows share a label: {bare:?}" + ); + } + + #[test] + fn routing_indices_map_back_to_the_right_parameter() { + // A wrong index would silently move the wrong slider's value, which + // is exactly the kind of bug that looks like a rendering fault. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + for (oi, op) in caps.iter().enumerate() { + for (pi, p) in op.params.iter().enumerate() { + assert_eq!(caps[oi].params[pi].id, p.id); + assert_eq!(caps[oi].id, op.id); + } + } + } +} diff --git a/ui/dr-ui/src/develop/segmentation.rs b/ui/dr-ui/src/develop/segmentation.rs new file mode 100644 index 0000000..42c53db --- /dev/null +++ b/ui/dr-ui/src/develop/segmentation.rs @@ -0,0 +1,834 @@ +//! Running the scene segmentation model off the UI thread, and the session +//! state that adopts what it finds (S15, docs/dev/segmentation.md). +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass}; +use dr_pipeline::mask::MaskSource; +use dr_pipeline::{CropRect, EditGraph}; + +use crate::segmentation::{self, Segmentation}; + +use super::session::DevelopSession; +use super::SEGMENT_PROXY_EDGE; + +/// Which photograph a piece of background work was started for. +/// +/// Minted per session, never reused, and carried by the work rather than +/// looked up when it finishes. A segmentation takes most of a second, so the +/// user can be two frames further on by the time one lands, and the answer to +/// "is this still wanted" has to be decided from what the work *was* rather +/// than from what happens to be open. +/// +/// The alternative — a counter beside the session slot, bumped on every open — +/// is written from four places in `lib.rs` and would apply one photograph's +/// subjects to another the first time somebody added a fifth and forgot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionId(u64); + +impl SessionId { + pub(crate) fn next() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(1); + Self(NEXT.fetch_add(1, Ordering::Relaxed)) + } +} + +/// Says that nobody is waiting for a job's answer any more. +/// +/// Not a cancellation in the sense of stopping the work: the model is one +/// opaque call of about half a second and `ort` offers no way in. This is +/// checked at the seams there are — before the job starts, and again between +/// the proxy readback and the inference — so a job abandoned while the user +/// was still paging usually costs nothing, and one abandoned mid-inference +/// costs only the run it was already committed to. +/// +/// What it buys in every case is that the next photograph's segmentation is +/// the only one anybody is waiting on. +#[derive(Debug, Clone, Default)] +pub struct Abandon(Arc); + +impl Abandon { + pub fn now(&self) { + self.0.store(true, Ordering::Relaxed); + } + + pub fn asked(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + +/// What a finished [`SegmentationJob`] hands back. +/// +/// The rasteriser travels with the subjects because it is needed the instant +/// they arrive and nowhere before. Building it is a shader compile — 23 ms on +/// a desktop, and compiling shaders is among the slowest things a mobile +/// driver does — so building it on adoption put a dropped frame on the one +/// redraw the user is waiting for. Here it is on the thread that was waiting +/// anyway. +/// +/// `None` where the device has no mask rasteriser at all: the session keeps +/// the photograph and loses local adjustments, which is the same bargain the +/// histogram makes. +pub struct Segmented { + seg: Segmentation, + masks: Option, +} + +/// TRACES: FR-DEV-3 +/// A segmentation lifted out of the session that asked for it. +/// +/// [`DevelopSession`] cannot go to a worker. Not because of what it holds — +/// the device, the source texture and the passes are all `Send` — but because +/// it lives behind one `Rc>>` that every callback in the +/// window reaches through, and the window has to keep reaching through it +/// while the work runs. Handing the session over would freeze the interface +/// exactly as thoroughly as blocking on it did. +/// +/// So the work takes a copy of the two things it needs. The device is `Arc`s, +/// the source is shared rather than copied, and the answer comes back as plain +/// data. +pub struct SegmentationJob { + ctx: GpuContext, + source: Arc, + session: SessionId, + abandon: Abandon, + /// TRACES: FR-CULL-10 + /// Confirmed faces in this photograph, **normalised to the long edge** of + /// the EXIF-upright image. + /// + /// Carried rather than looked up, because the job runs on a thread with no + /// catalog in reach — the same reason it carries the pixels. Normalised + /// rather than in pixels because the proxy size is only settled inside + /// `run`. + names: Vec, + /// TRACES: FR-CULL-10 + /// The file's EXIF turn alone, which is the space `names` is expressed in. + /// + /// **Deliberately not [`SegmentationJob::orientation`].** Faces are found + /// on the thumbnail, which is stood up by the EXIF tag and knows nothing + /// about the photographer's later turns; the segmentation below composes + /// both. Using the composed one here would turn the faces twice on any + /// photograph the user has rotated, and the failure would be silent — + /// names simply landing on nobody. + exif_orientation: dr_types::Orientation, + /// TRACES: FR-DEV-3h + /// How the sensor's pixels have to be turned to be the photograph. + /// + /// The file's EXIF tag and the photographer's own turns, composed into + /// one permutation by `Framing::effective_orientation`. Carried rather + /// than read from the session for the reason everything else here is: the + /// job runs on a worker and the session stays behind. + /// + /// A *snapshot*, so turning the photograph while a run is in flight + /// leaves that run answering the question it was asked. The next press + /// takes the new one, and the signature says the two are different runs. + orientation: dr_types::Orientation, +} + +impl SegmentationJob { + /// The photograph this was started for. + pub fn session(&self) -> SessionId { + self.session + } + + /// The handle that tells this job its answer is no longer wanted. + pub fn abandon(&self) -> Abandon { + self.abandon.clone() + } + + /// TRACES: FR-DEV-3 + /// Find the subjects. **Blocking, and roughly two thirds of a second.** + /// + /// `Ok(None)` means abandoned rather than found-nothing: an image with no + /// recognisable subject in it still comes back as `Ok(Some(_))` with an + /// empty instance list, and the panel says so. + /// + /// There is deliberately no `&mut DevelopSession` in scope here. That is + /// the whole point of the split — a caller cannot accidentally hold the + /// session across the half second, because it was never given one. + pub fn run(&self, options: &segmentation::Options) -> Result, String> { + if self.abandon.asked() { + return Ok(None); + } + + // Timed and logged because this is the feature's largest cost and the + // split between the two halves decides where any further work goes. A + // number from the device it actually runs on beats an estimate from + // the desktop. + let started = std::time::Instant::now(); + let (rgb, rw, rh) = self.neutral_proxy(SEGMENT_PROXY_EDGE)?; + let proxied = started.elapsed(); + + // The one seam inside the run. Past here the model owns the thread + // until it is done. + if self.abandon.asked() { + return Ok(None); + } + + let mut seg = segmentation::compute(&self.ctx, &rgb, rw, rh, self.orientation, options)?; + + // TRACES: FR-CULL-10 + // Put names on the people the segmenter found. + // + // `compute` stands the frame up to detect and lays the instances back + // down, so `bbox` is in the sensor's space. The faces came off the + // thumbnail and are in the EXIF-upright one. Two different spaces, and + // on a portrait photograph they are a quarter turn apart — so the + // faces are turned down to meet the instances, through the same + // `Orientation` map every other consumer uses rather than a second + // copy of the arithmetic. + // + // The catalog normalises a face to the image's **long edge**, where + // `ShownRect` is normalised per axis; the conversions either side of + // the turn are that difference and nothing more. + if !self.names.is_empty() { + let long_edge = rw.max(rh) as f32; + let (dw, dh) = self.exif_orientation.oriented_size(rw as u32, rh as u32); + let (dw, dh) = (dw as f32, dh as f32); + + let boxes: Vec> = self + .names + .iter() + .map(|(x, y, w, h, name)| { + let shown = dr_types::ShownRect { + x: x * long_edge / dw, + y: y * long_edge / dh, + width: w * long_edge / dw, + height: h * long_edge / dh, + }; + let stored = self.exif_orientation.into_stored_rect(shown); + dr_face::NamedFace { + bbox: ( + stored.x * rw as f32, + stored.y * rh as f32, + (stored.x + stored.width) * rw as f32, + (stored.y + stored.height) * rh as f32, + ), + name, + } + }) + .collect(); + + let named = seg.apply_names(&boxes); + if named > 0 { + log::info!("named {named} segmented region(s) from known faces"); + } + } + + log::info!( + "segmented {rw}×{rh}: {} subject(s), proxy {:.0} ms, total {:.0} ms", + seg.instances().len(), + proxied.as_secs_f32() * 1000.0, + started.elapsed().as_secs_f32() * 1000.0, + ); + + let masks = MaskPass::new(&self.ctx) + .inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}")) + .ok(); + Ok(Some(Segmented { seg, masks })) + } + + /// Render the *unedited* image to a CPU buffer at proxy size. + /// + /// The model reads the photograph as captured, not as edited: the + /// segmentation must survive an exposure change, or every slider would + /// invalidate the masks that depend on it (docs/dev/segmentation.md §3). + /// + /// **Still the sensor's orientation, deliberately.** Standing the picture + /// up is what `segmentation::compute` does to the buffer this returns, + /// and it is done there rather than here because a mask has to come back + /// in this space: the generated shader samples the mask array at `uv_src`, + /// after the framing map. Rendering an upright proxy would put every mask + /// a quarter turn away from the subject it was drawn around — a wrong + /// mask rather than a weak one, and nothing would announce it. + /// + /// A throwaway [`AdjustPass`] with a neutral graph rather than the + /// session's own — which this could not reach from here in any case, and + /// must not: reusing it would overwrite the frame the histogram reads and + /// leave the view showing an unedited image until the next redraw. + /// + /// This is `export_pixels`, which is ungated: an export is not the display + /// round-trip AC-8 forbids, and neither is this. + fn neutral_proxy(&self, max_edge: u32) -> Result<(Vec, usize, usize), String> { + let (sw, sh) = self.source.size(); + let scale = (max_edge as f32 / sw.max(sh) as f32).min(1.0); + let (w, h) = ( + ((sw as f32 * scale) as u32).max(1), + ((sh as f32 * scale) as u32).max(1), + ); + + let neutral = EditGraph::default_chain(); + let mut pass = AdjustPass::new(&self.ctx); + pass.render(&self.source, &neutral.compose(), w, h) + .map_err(|e| format!("could not render the segmentation proxy: {e}"))?; + let (rgba, pw, ph) = pass + .export_pixels() + .map_err(|e| format!("could not read the segmentation proxy: {e}"))?; + + // Straight to float RGB, dropping alpha. The values stay display- + // encoded because that is what the model was trained on — one of the + // few places in this codebase where not linearising is correct. + let rgb = rgba + .chunks_exact(4) + .flat_map(|p| { + [ + p[0] as f32 / 255.0, + p[1] as f32 / 255.0, + p[2] as f32 / 255.0, + ] + }) + .collect(); + Ok((rgb, pw as usize, ph as usize)) + } +} + +/// Longest edge the refine crop is rendered at. +/// +/// Matches [`dr_segment::semantic::INPUT_EDGE`] rather than exceeding it: the +/// model's own input is still fixed at 640x640, so rendering the crop larger +/// only gets downsampled again inside the model's letterbox. The resolution +/// win is entirely from *what fills the window* — a padded crop around one +/// subject rather than the whole frame — not from feeding the model more +/// pixels than it has ever read. +const REFINE_EDGE: u32 = 640; + +/// How much of the box's own size is added on each side before cropping. +/// +/// Context for the model to place the subject's edge against, and slack for +/// a box that under-ran the subject slightly on the first pass. Not so much +/// that a second subject standing nearby gets pulled into the same window. +const REFINE_PADDING: f32 = 0.25; + +/// TRACES: FR-DEV-3 +/// A refine pass lifted out of the session that asked for it. +/// +/// The same split as [`SegmentationJob`], for the same reason — see its +/// docs. This one answers for a single already-detected instance rather than +/// the whole frame: it re-runs the model over a padded crop of just that +/// subject's box, so the subject reaches the model at its own size instead +/// of squeezed into the model's fixed window alongside everything else in +/// the photograph. +pub struct RefineJob { + ctx: GpuContext, + source: Arc, + session: SessionId, + abandon: Abandon, + /// Which instance this answers for. A snapshot of what it needs from the + /// segmentation, taken when the job was built — the same reason + /// `SegmentationJob` carries a proxy render rather than the session. + index: usize, + class_name: Arc, + bbox: (f32, f32, f32, f32), + proxy: (usize, usize), + /// The same permutation, for the same reason — see + /// [`SegmentationJob::orientation`]. A refine pass that read the crop + /// sideways would hand back a worse mask than the one it was asked to + /// improve, on the subject the photographer had just pointed at. + orientation: dr_types::Orientation, +} + +impl RefineJob { + pub fn session(&self) -> SessionId { + self.session + } + + pub fn abandon(&self) -> Abandon { + self.abandon.clone() + } + + /// TRACES: FR-DEV-3 + /// Re-detect this one subject at higher effective resolution. **Blocking.** + /// + /// `Ok(None)` covers both "abandoned" and "the model found nothing of the + /// same class in the crop" — a photographer pressing refine on a subject + /// that the padded window no longer contains is a possible outcome, not + /// a bug, and the caller treats it as "kept what was there" either way. + pub fn run(&self) -> Result, String> { + if self.abandon.asked() { + return Ok(None); + } + + let (px0, py0, px1, py1) = self.bbox; + let (pw, ph) = (self.proxy.0 as f32, self.proxy.1 as f32); + if pw <= 0.0 || ph <= 0.0 { + return Ok(None); + } + + let (bw, bh) = ((px1 - px0).max(1.0), (py1 - py0).max(1.0)); + let (padx, pady) = (bw * REFINE_PADDING, bh * REFINE_PADDING); + let x0 = (px0 - padx).max(0.0); + let y0 = (py0 - pady).max(0.0); + let x1 = (px1 + padx).min(pw); + let y1 = (py1 + pady).min(ph); + if x1 <= x0 || y1 <= y0 { + return Ok(None); + } + let (cw_px, ch_px) = (x1 - x0, y1 - y0); + + let view = CropRect { + x: x0 / pw, + y: y0 / ph, + width: cw_px / pw, + height: ch_px / ph, + }; + + // Aspect-matched render target, capped against blowing a tiny box up + // absurdly far past what the source ever had to offer. + let scale = (REFINE_EDGE as f32 / cw_px.max(ch_px)).min(4.0); + let (tw, th) = ( + (cw_px * scale).round().max(1.0) as u32, + (ch_px * scale).round().max(1.0) as u32, + ); + + if self.abandon.asked() { + return Ok(None); + } + let (rgb, rw, rh) = self.render_view(view, tw, th)?; + if self.abandon.asked() { + return Ok(None); + } + + // Stood up before the model reads it and laid back down after, the + // same way the whole-frame pass does it — `segmentation::upright` is + // the one place that permutation is written. A crop rendered in + // sensor space is exactly as sideways as the frame it came from. + let (upright, uw, uh) = segmentation::upright(&rgb, rw, rh, self.orientation); + + let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?; + let options = dr_segment::SemanticOptions { + tiling: dr_segment::Tiling::Whole, + ..dr_segment::SemanticOptions::default() + }; + let found = model + .detect(&upright, uw, uh, &options) + .map_err(|e| e.to_string())?; + + // The crop was built around one subject, so the right answer among + // whatever the model found in it is the same class closest to the + // window's centre — not merely the highest score, which a second, + // unrelated instance caught in the padding could win. + // + // Measured in the upright frame, which is where the model's boxes are. + // The centre is the centre either way; the distances are not, once the + // window is not square. + let (cx, cy) = (uw as f32 * 0.5, uh as f32 * 0.5); + let best = found + .into_iter() + .filter(|i| i.class_name.as_ref() == self.class_name.as_ref()) + .min_by(|a, b| centre_distance(a, cx, cy).total_cmp(¢re_distance(b, cx, cy))); + + let Some(instance) = best else { + return Ok(None); + }; + + // Back into the crop's own sensor-space pixels, so everything below + // this line measures in the space `self.bbox` and `self.proxy` are in. + let (crop_mask, crop_bbox) = + segmentation::lay_down(&instance.mask, instance.bbox, uw, uh, self.orientation); + + // Downsampled back onto the shared proxy grid like every other + // instance's mask is, but built from a sharper source than the + // whole-frame pass ever saw for this subject. + let mask = paste_into_proxy(&crop_mask, rw, rh, self.proxy, (x0, y0), (cw_px, ch_px)); + let bbox = ( + x0 + crop_bbox.0 / scale, + y0 + crop_bbox.1 / scale, + x0 + crop_bbox.2 / scale, + y0 + crop_bbox.3 / scale, + ); + + Ok(Some(RefinedInstance { + index: self.index, + summary: segmentation::InstanceSummary { + class_name: instance.class_name, + score: instance.score, + mask, + bbox, + }, + })) + } + + /// Render the *unedited* image, showing only `view`, at `(width, height)`. + /// + /// The same neutral, as-captured render [`SegmentationJob::neutral_proxy`] + /// uses — a refine pass must read the same kind of pixels the first pass + /// did, or a subject would gain or lose an edge depending on which pass + /// found it. `view` is framing's ephemeral viewport (`Framing::set_view`), + /// the mechanism the on-screen zoom already uses to render a region at + /// more than proxy resolution — not the crop tool's own persisted + /// rectangle, and nothing here touches that. + fn render_view( + &self, + view: CropRect, + width: u32, + height: u32, + ) -> Result<(Vec, usize, usize), String> { + let mut neutral = EditGraph::default_chain(); + neutral.framing_mut().set_view(view); + + let mut pass = AdjustPass::new(&self.ctx); + pass.render(&self.source, &neutral.compose(), width, height) + .map_err(|e| format!("could not render the refine crop: {e}"))?; + let (rgba, pw, ph) = pass + .export_pixels() + .map_err(|e| format!("could not read the refine crop: {e}"))?; + + let rgb = rgba + .chunks_exact(4) + .flat_map(|p| { + [ + p[0] as f32 / 255.0, + p[1] as f32 / 255.0, + p[2] as f32 / 255.0, + ] + }) + .collect(); + Ok((rgb, pw as usize, ph as usize)) + } +} + +/// What a finished [`RefineJob`] hands back: a replacement for one instance +/// in the segmentation it was run against. +pub struct RefinedInstance { + index: usize, + summary: segmentation::InstanceSummary, +} + +fn centre_distance(instance: &dr_segment::Instance, cx: f32, cy: f32) -> f32 { + let (x0, y0, x1, y1) = instance.bbox; + let (ix, iy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5); + ((ix - cx).powi(2) + (iy - cy).powi(2)).sqrt() +} + +/// Sample a crop-local mask back onto its footprint in the shared proxy grid. +/// +/// Bilinear, the same as every other resampling in this mask pipeline +/// (`dr_segment::semantic`'s own prototype sampling, the letterbox that feeds +/// it) — correct whether the crop was rendered denser than the proxy (the +/// common case this feature exists for) or coarser than it (a subject large +/// enough that refining it buys little, which still renders a sensible if +/// unremarkable answer rather than a distorted one). +fn paste_into_proxy( + crop_mask: &[f32], + crop_w: usize, + crop_h: usize, + proxy: (usize, usize), + origin_px: (f32, f32), + extent_px: (f32, f32), +) -> Vec { + let (pw, ph) = proxy; + let mut out = vec![0u8; pw * ph]; + let (ew, eh) = extent_px; + if crop_w == 0 || crop_h == 0 || pw == 0 || ph == 0 || ew <= 0.0 || eh <= 0.0 { + return out; + } + + let (ox, oy) = origin_px; + let px0 = ox.floor().max(0.0) as usize; + let py0 = oy.floor().max(0.0) as usize; + let px1 = ((ox + ew).ceil() as usize).min(pw); + let py1 = ((oy + eh).ceil() as usize).min(ph); + + for py in py0..py1 { + let gy = (py as f32 + 0.5 - oy) / eh * crop_h as f32; + if gy < 0.0 || gy >= crop_h as f32 { + continue; + } + for px in px0..px1 { + let gx = (px as f32 + 0.5 - ox) / ew * crop_w as f32; + if gx < 0.0 || gx >= crop_w as f32 { + continue; + } + let v = bilinear_sample(crop_mask, crop_w, crop_h, gx, gy); + out[py * pw + px] = (v.clamp(0.0, 1.0) * 255.0).round() as u8; + } + } + out +} + +fn bilinear_sample(mask: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 { + let (fx0, fy0) = (x.floor(), y.floor()); + let (fx, fy) = (x - fx0, y - fy0); + let x0 = (fx0 as isize).clamp(0, w as isize - 1) as usize; + let y0 = (fy0 as isize).clamp(0, h as isize - 1) as usize; + let x1 = (x0 + 1).min(w - 1); + let y1 = (y0 + 1).min(h - 1); + let at = |x: usize, y: usize| mask[y * w + x]; + let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx; + let bot = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx; + top * (1.0 - fy) + bot * fy +} + +impl DevelopSession { + // ---------------------------------------------------------------------- + // Segmentation (S15, docs/dev/segmentation.md) + // ---------------------------------------------------------------------- + + /// This session's name, carried by any work started against it. + pub fn id(&self) -> SessionId { + self.id + } + + /// TRACES: FR-DEV-3 + /// Everything a segmentation needs, so it can be run somewhere else. + /// + /// Taking the job is cheap — two `Arc` bumps and a texture handle — and + /// nothing about the session is borrowed past the call, which is what + /// lets the window go on drawing while the answer is being found. + pub fn segmentation_job(&self) -> SegmentationJob { + SegmentationJob { + ctx: self.ctx.clone(), + source: self.demosaiced.clone(), + session: self.id, + abandon: Abandon::default(), + names: self.face_names.clone(), + exif_orientation: self.orientation, + orientation: self.graph.framing().effective_orientation(), + } + } + + /// TRACES: FR-CULL-10 | FR-DEV-3 + /// The confirmed faces in this photograph, for naming segmented regions. + /// + /// Set once when the image opens, because that is the only moment the + /// catalog and the image id are both in reach — the develop session + /// deliberately knows nothing about either, and every segmentation run + /// after this point picks the names up for free. + /// + /// Boxes are normalised to the long edge, as the catalog stores them, so + /// they survive whatever proxy size a run settles on. + pub fn set_face_names(&mut self, names: Vec) { + self.face_names = names; + } + + /// TRACES: FR-DEV-3 + /// Take on a segmentation found elsewhere. + /// + /// The caller is responsible for checking that this result was computed + /// for *this* session — see [`SegmentationJob::session`]. Nothing here can + /// tell one photograph's subjects from another's, and a mismatch is + /// silent: the masks would rasterise, the overlay would draw, and the + /// outlines would simply follow a subject that is not in the picture. + pub fn adopt_segmentation(&mut self, found: Segmented) { + // Kept rather than replaced where there is one already: a second + // segmentation of the same photograph would otherwise throw away a + // working rasteriser for an identical one. + self.masks = self.masks.take().or(found.masks); + + // The fields themselves are built per *layer*, on demand — there are + // none yet, and building one per detected object would transform + // several megapixels for masks the user may never make. + self.segmentation = Some(found.seg); + self.subjects = None; + self.subject_key = 0; + } + + /// TRACES: FR-DEV-3 + /// Everything a refine pass needs for one already-detected instance, so + /// it can be run somewhere else — see [`Self::segmentation_job`] for why + /// the split exists. + /// + /// `None` where there is nothing to refine: no segmentation yet, or an + /// index the panel offered a button for a moment ago but the segmentation + /// underneath has since changed. + pub fn refine_job(&self, index: usize) -> Option { + let seg = self.segmentation.as_ref()?; + let instance = seg.instances().get(index)?; + Some(RefineJob { + ctx: self.ctx.clone(), + source: self.demosaiced.clone(), + session: self.id, + abandon: Abandon::default(), + index, + class_name: instance.class_name.clone(), + bbox: instance.bbox, + proxy: seg.proxy_size(), + orientation: self.graph.framing().effective_orientation(), + }) + } + + /// Take on a refined instance found elsewhere. + /// + /// Same caution as [`Self::adopt_segmentation`]: the caller checks the + /// job's session before handing back its answer, not this. Every mask + /// layer pointing at this instance's index reads it fresh next redraw — + /// `subject_key` is reset because the pixels changed under an index + /// `subject_signature` has no way to know changed, unlike a morphology + /// slider it does track. + pub fn adopt_refined(&mut self, refined: RefinedInstance) { + if let Some(seg) = self.segmentation.as_mut() { + seg.replace_instance(refined.index, refined.summary); + } + self.subjects = None; + self.subject_key = 0; + } + + /// The mask layer's original detection index, for the refine button — + /// `None` for a layer that is not a subject at all (a gradient has no + /// instance to re-detect). + pub fn subject_instance_index(&self, id: &str) -> Option { + match &self.graph.masks().get(id)?.base().source { + MaskSource::Subject { index, .. } => Some(*index as usize), + _ => None, + } + } + + /// Find the subjects and take them on, blocking until both are done. + /// + /// Test-only, and deliberately: a session-shaped blocking call is exactly + /// the shape that put two thirds of a second on the UI thread in the first + /// place, and leaving it public would invite the next caller to reach for + /// it. A test has nothing else to be doing. + #[cfg(test)] + pub(super) fn segment(&mut self, options: &segmentation::Options) -> Result<(), String> { + if let Some(found) = self.segmentation_job().run(options)? { + self.adopt_segmentation(found); + } + Ok(()) + } + + pub fn has_segmentation(&self) -> bool { + self.segmentation.is_some() + } + + /// The subjects the model recognised, as `(label, confidence)`. + /// + /// Confidence is shown rather than hidden because the detector is offered + /// as a shortcut, not as an authority: a 0.42 "dog" is worth listing and + /// worth flagging, and a list that presented it identically to a 0.95 one + /// would make the tool look wrong when the guess was merely weak. + pub fn detected_subjects(&self) -> Vec<(String, f32)> { + self.segmentation + .as_ref() + .map(|s| { + s.instances() + .iter() + .map(|i| (i.class_name.to_string(), i.score)) + .collect() + }) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::develop::test_support::*; + + // ---------------------------------------------------------------------- + // Segmentation off the UI thread + // ---------------------------------------------------------------------- + + /// The property the whole arrangement rests on. + /// + /// If someone puts an `Rc`, a `Cell` or a raw pipeline handle into + /// `SegmentationJob`, this stops compiling — which is the only warning + /// there would be, since the call site in `masks_ui` would then fail with + /// a lifetime error a long way from the cause. + #[test] + fn a_job_and_its_answer_can_cross_a_thread() { + fn is_send() {} + is_send::(); + is_send::(); + is_send::(); + } + + /// Two sessions over the same file are still two photographs as far as a + /// late result is concerned, because opening one twice is opening it + /// twice. + #[test] + fn every_session_has_its_own_identity() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); + let open = || { + DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) + .expect("session") + }; + let (a, b) = (open(), open()); + assert_ne!(a.id(), b.id()); + assert_eq!(a.id(), a.id(), "and stable within one session"); + } + + #[test] + fn a_job_carries_the_session_it_was_taken_from() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); + let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) + .expect("session"); + assert_eq!(session.segmentation_job().session(), session.id()); + } + + /// Abandoning before the run reaches the proxy must cost nothing at all — + /// this is the case that fires when the user pages on while a job is still + /// waiting for a thread. + #[test] + fn an_abandoned_job_does_no_work() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); + let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) + .expect("session"); + + let job = session.segmentation_job(); + job.abandon().now(); + + let started = std::time::Instant::now(); + let out = job.run(&crate::segmentation::Options::default()); + assert!( + matches!(out, Ok(None)), + "abandoned is not an error and not an empty answer: {:?}", + out.map(|o| o.is_some()) + ); + assert!( + started.elapsed() < std::time::Duration::from_millis(50), + "it returned without loading the model" + ); + } + + /// A finished segmentation is adopted whole, and the session says so. + #[test] + fn adopting_a_result_gives_the_session_its_subjects() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL) + .expect("session"); + assert!(!session.has_segmentation()); + + let job = session.segmentation_job(); + let Ok(Some(found)) = job.run(&crate::segmentation::Options::default()) else { + eprintln!("no model; skipping"); + return; + }; + session.adopt_segmentation(found); + assert!(session.has_segmentation()); + } + + /// TRACES: FR-DEV-3 + /// A refine crop's mask, pasted back onto the proxy grid it replaces, + /// must land exactly where the crop was — not shifted by the origin, not + /// scaled onto the wrong footprint. + #[test] + fn a_refined_mask_pastes_back_at_the_crops_own_position() { + // A crop entirely on (1.0), covering proxy pixels 2..6 in x and + // 2..6 in y of an 8x8 proxy — everywhere inside that box must read + // back as fully covered, everywhere outside as untouched. + let crop = vec![1.0f32; 4 * 4]; + let mask = paste_into_proxy(&crop, 4, 4, (8, 8), (2.0, 2.0), (4.0, 4.0)); + + assert_eq!(mask[2 * 8 + 2], 255, "top-left corner of the box"); + assert_eq!(mask[5 * 8 + 5], 255, "bottom-right corner of the box"); + assert_eq!(mask[0], 0, "outside the box, untouched"); + assert_eq!(mask[7 * 8 + 7], 0, "outside the box, untouched"); + } + + #[test] + fn bilinear_sample_averages_its_four_neighbours() { + // Two rows, black then white: the exact midpoint reads as grey. + let mask = [0.0f32, 0.0, 1.0, 1.0]; + let v = bilinear_sample(&mask, 2, 2, 0.5, 0.5); + assert!( + (v - 0.5).abs() < 1e-6, + "expected the midpoint grey, got {v}" + ); + } +} diff --git a/ui/dr-ui/src/develop/session.rs b/ui/dr-ui/src/develop/session.rs new file mode 100644 index 0000000..294fea8 --- /dev/null +++ b/ui/dr-ui/src/develop/session.rs @@ -0,0 +1,783 @@ +//! The develop session's identity, lifecycle and file metadata. +//! +//! `DevelopSession` itself, opening a photograph, and the small set of +//! whole-session queries (settings, metadata) that do not belong to any of +//! the more specific areas split out alongside this one. +use dr_decode::RawImage; +use dr_gpu::{ + AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, + HistogramPass, MaskPass, RawHistogram, RawHistogramPass, +}; +use dr_pipeline::{CropRect, Edit, EditGraph, History, Preset, Scope}; +use std::sync::Arc; + +use crate::labels; +use crate::segmentation::Segmentation; + +use super::masks::MaskView; +use super::segmentation::SessionId; + +pub struct DevelopSession { + /// This session's name, for work that outlives the frame it started on. + pub(super) id: SessionId, + /// TRACES: FR-CULL-10 + /// Confirmed faces in this photograph, normalised to the long edge. + /// + /// Empty until [`DevelopSession::set_face_names`] is called, and empty for + /// ever on a library with no face indexing — in which case segmentation + /// behaves exactly as it did before, which is the point. + pub(super) face_names: Vec, + /// How this photograph is stored relative to how it is shown. + /// + /// Kept because the segmentation proxy is rendered through a *neutral* + /// graph and is therefore in sensor order, while faces were found on the + /// upright thumbnail. On anything shot in portrait the two differ by a + /// quarter turn, and matching them without undoing it finds nothing. + pub(super) 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`. + pub(super) source_meta: Option, + /// Whether the lens in the header matched a profile in the database. + /// + /// A separate flag rather than `graph.lens_profile().is_some()`, because + /// the two answer different questions once the photographer starts work: + /// the graph says what is *applied*, which a manual correction also + /// satisfies, and this says whether a *measurement* was found. Only the + /// second can honestly caption "no profile". + pub(super) lens_profile_found: bool, + /// Kept so the session can build GPU resources after construction. + /// + /// The distance fields behind a subject mask are made when a layer is + /// *shaped*, not when the image opens, and cloning a `GpuContext` is two + /// `Arc` bumps. + pub(super) ctx: GpuContext, + pub(super) graph: EditGraph, + /// TRACES: FR-DEV-5 + /// Undo, kept beside the graph rather than in the window. + /// + /// Every mutator below records into it, so a caller cannot change the edit + /// and forget to. That is the whole reason it lives here: the callbacks in + /// `lib.rs` are generic by construction and there are a dozen of them, and + /// a history the *call sites* had to remember would be one press of undo + /// away from wrong every time a control is added. + pub(super) history: History, + /// TRACES: FR-DEV-5 + /// The named snapshots of this edit, as the sidecar had them plus what + /// this sitting took, minus what it deleted. + /// + /// Beside the history rather than inside it, because they answer a + /// different question. The history is what was done in this sitting and + /// is deliberately forgotten with it; a snapshot is a state the + /// photographer *named*, which is the act of saying it should outlive + /// the sitting. It is persisted as a version of the sidecar pointing at + /// this one (`Version::snapshot_of`), which is what makes it survive a + /// restart and reach the other device. + pub(super) snapshots: Vec, + /// The ids of snapshots deleted this sitting, so the save can remove + /// them from the file without removing what another device added since + /// — see `Sidecar::replace_snapshots`. + pub(super) removed_snapshots: Vec, + /// TRACES: FR-DEV-7 + /// The snapshot the canvas is showing instead of the edit, while a + /// comparison is held. Viewing state: nothing about the edit changes, + /// and it goes down with the session. + pub(super) compared_snapshot: Option, + pub(super) demosaiced: Arc, + pub(super) adjust: AdjustPass, + /// TRACES: FR-DSP-7 + /// Optional, because a session that cannot count its frames is still a + /// session that can develop them. If the reduction fails to build — an + /// old driver, a device without the storage-buffer atomics it needs — the + /// photographer loses the histogram and keeps the photograph. + pub(super) histogram: Option, + /// TRACES: FR-CULL-3 + /// The raw-domain reduction, on the same terms as the display one above: + /// optional, because a session that cannot count the sensor data is still + /// a session that can develop it. + pub(super) raw_histogram: Option, + /// The raw reading, once taken. + /// + /// **Cached, where the display histogram is recomputed every settled + /// frame, and the difference is not an optimisation.** This measures the + /// demosaiced source, which nothing downstream of the demosaic can change: + /// no slider, no crop, no zoom, no output space moves a single count in + /// it. Recomputing it per frame would be a dispatch and a device sync + /// point spent to arrive back at the number already held — and on the + /// culling pass FR-CULL-3 is written for, that is a cost paid three + /// thousand times over. + /// + /// `None` until first asked for, and it stays `None` on a file with no + /// sensor data behind it. The session is one photograph and the demosaiced + /// source is fixed for its life, so there is no invalidation to get wrong. + pub(super) raw_counts: Option, + /// TRACES: FR-CULL-3 + /// The focus-peaking overlay, on the same terms as the histogram above: + /// optional, because a device that cannot compile the pass is still a + /// device that can develop the photograph. What is lost is an instrument, + /// not the picture. + pub(super) peak: Option, + /// TRACES: FR-CULL-3 + /// What the photographer asked the overlay to look like, or `None` for + /// off. + /// + /// **Interface state, not part of the edit** — the same category as + /// `show_overlay` beside it. It changes no pixel of the photograph, it is + /// not in the sidecar, and it is not on the undo stack: pressing undo + /// after switching peaking on should take back the last *edit*, not the + /// last thing looked at. + /// + /// An `Option` rather than a bool plus a settings field, so that "off" and + /// "on, in some configuration" cannot disagree with each other. + pub(super) peaking: Option, + + /// TRACES: FR-DEV-3 + /// The region map local masks select from, once it has been computed. + /// + /// `None` until the photographer asks for it. Segmentation costs about + /// half a second and most edits never need one, so running it on open + /// would tax every photograph for a feature used on some of them. + pub(super) segmentation: Option, + /// Rasterises the mask layers. Built lazily for the same reason. + pub(super) masks: Option, + /// One signed distance field per active subject layer, on the GPU. + pub(super) subjects: Option, + /// What `subjects` was built from. + /// + /// The fields are expensive — an exact distance transform over the proxy + /// for each layer — and almost nothing changes them. Feather, falloff and + /// simple growing are arithmetic the shader does on the field it already + /// has, so this deliberately does *not* include them: dragging those + /// sliders must not rebuild anything. + pub(super) subject_key: u64, + /// Which layers the develop panel is editing, if any. + /// + /// This is what lets one panel serve both scopes: with layers selected, + /// the sliders read and write *their* chains, and the photographer is + /// adjusting one or more regions rather than the frame. + /// + /// A plain click replaces this outright; a modifier-click toggles one id + /// in or out, so several layers can be shaped by the same slider drag — + /// "make these three subjects a stop darker" is one gesture rather than + /// three. Order is insertion order and nothing reads it, only membership. + pub(super) active_masks: Vec, + /// TRACES: FR-DEV-19a + /// Which part of the selected layer the tools and the edge controls point + /// at. Zero — the base — whenever a layer is selected afresh. + /// + /// An index rather than an id, because it addresses a row the panel is + /// already showing by position, and because a gesture that outlived the + /// part it was aimed at would be a stroke landing somewhere nobody asked + /// for. Clamped on the way in and re-checked on the way out. + pub(super) active_part: usize, + /// TRACES: FR-DEV-19b + /// The layer and part a stroke in progress is going into. + /// + /// Captured on press and held for the gesture: the panel's selection can + /// change under a finger — a stray tap, a sync arriving — and a stroke + /// that changed target half way through would leave half a mark in each. + pub(super) painting: Option<(String, usize)>, + /// TRACES: FR-DEV-19b + /// The brush: radius as a fraction of the frame's shorter edge, hardness, + /// and flow. + /// + /// On the session rather than on a layer, because it belongs to the + /// *tool*: somebody who sets a small eraser expects it to still be small + /// the next time they erase, whichever mask they are working on. + pub(super) brush: (f32, f32, f32), + /// TRACES: FR-DEV-8 + /// Which repair the panel is describing, if any. + /// + /// Interface state and not part of the edit, exactly as `active_masks` is: + /// it changes no pixel, it is not in the sidecar, and it is not on the undo + /// stack. One at a time rather than a set — a repair is eight numbers and + /// there is no gesture that usefully moves several at once, where three + /// masked layers really can share a slider drag. + pub(super) selected_spot: Option, + /// Whether to draw the false-coloured region overlay. + pub(super) show_overlay: bool, + /// TRACES: FR-DEV-19c + /// How shown masks are drawn — one style for all of them. + /// + /// Interface state, like `show_overlay` beside it and `active_masks` above + /// — it changes no pixel of the photograph, it is not in the sidecar and + /// it is not on the undo stack. It reaches the pipeline as an argument to + /// the one composition that draws the canvas, which is what makes an + /// export structurally unable to carry it (`EditGraph::compose_revealing`). + pub(super) reveal_style: dr_pipeline::mask::RevealStyle, + /// TRACES: FR-DEV-19c + /// Per layer: whether its mask is shown, and in what colour. + /// + /// Per layer rather than "the selected one", because the question a + /// photographer asks of two masks is how they meet — where the sky's edge + /// sits against the building's — and that needs both on screen at once, + /// in colours that can be told apart. Keyed by id, and an id that is no + /// longer in the stack is simply never asked for; `reveal` walks the + /// stack, not this map. + /// + /// Viewing state and not edit state, for the reason the style is: it does + /// not travel in a sidecar, so a photograph reopened has every eye closed. + pub(super) mask_views: std::collections::HashMap, + /// Which attribute the panel is filtered to, or all of them. + /// + /// `None` is "show everything" and is what a frontend that ignores + /// attributes leaves it at — the tabs are the interface's idea, not the + /// core's, and nothing breaks without them (ARCH §4.3a). + pub(super) active_tab: Option, + /// TRACES: FR-DEV-3 + /// Which of the curve widget's subjects the panel is plotting. + /// + /// The tone curve is four curves — one over tone and one per colour + /// channel — and one square plot draws one of them at a time. The index + /// is into the subjects the operation's parameters are faceted on, in the + /// order it declares them, so nothing here knows that "red" exists. + /// + /// **Interface state, not part of the edit.** It changes no pixel, so it + /// is not a parameter, it is not in the graph, it is not in the sidecar + /// and it is not on the undo stack — the same standing as which tab is + /// open. One value rather than one per operation, for the same reason + /// `curve_samples` is one polyline: the panel draws one curve. + pub(super) curve_channel: usize, + /// TRACES: FR-DSP-8 + /// The space the canvas is encoded into, for the display now showing it. + /// + /// **Not part of the edit, and not interface state either.** It is a fact + /// about the glass in front of the photographer: the same graph on the + /// same file composes differently on a wide-gamut second monitor, and + /// neither the sidecar nor the undo stack has any business knowing about + /// it. That is also why it lives here rather than on the `EditGraph` — + /// `compose_for` deliberately takes the space per call because "the same + /// edit goes to the screen in the display's space and to a file in + /// whatever the export asks for, and neither is more authoritative". + /// + /// sRGB until the application says otherwise, which is the same answer + /// `dr_plat::display`'s fallback gives and means a session constructed in + /// a test behaves exactly as it did before this existed. + /// TRACES: FR-DEV-3 + /// What the straightening auto-crop last wrote, and what it was derived + /// from: `(applied, intended)`. + /// + /// **The graph holds the corrected rectangle; this remembers the intent + /// behind it.** `auto_crop_to_angle` pulls the crop inside the area an + /// angle leaves defined, and that operation can only ever shrink. Applied + /// to its own output it ratchets — straighten to 20 degrees, come back to + /// 3, and the crop stays at the size 20 degrees demanded, which is not + /// what turning the slider back means. So the correction is never + /// accumulated: it is recomputed from the intent every time, and as the + /// angle falls the crop grows back and stops exactly where the user put + /// it. At zero degrees the safe area is the whole frame and the two are + /// equal again. + /// + /// **A pair rather than a single remembered rectangle, so it repairs + /// itself.** Every other route to the crop — a handle dragged, a ratio + /// chosen, a sidecar loaded, a paste, an undo — leaves the graph holding + /// something other than `applied`, and that mismatch is exactly the signal + /// that the remembered intent is stale. [`Self::intended_crop`] checks it + /// rather than requiring each of those paths to remember to write here, + /// which is the kind of bookkeeping that is correct until someone adds a + /// seventh path. + /// + /// Session-scoped. A sidecar records the crop that was *applied*, because + /// that is the one that describes the photograph, so reopening starts from + /// that rectangle as its own intent. + pub(super) auto_crop: Option<(CropRect, CropRect)>, + pub(super) display_space: dr_types::ColourSpace, +} + +impl DevelopSession { + /// Demosaic an image and prepare its edit graph. + /// + /// `orientation` is the file's EXIF orientation, not an edit: a sensor is + /// scanned the same way whichever way the body was held, so this is what + /// makes a portrait frame open upright. It is fixed for the life of the + /// session and survives a reset. + pub fn open( + ctx: &GpuContext, + raw: &RawImage, + orientation: dr_types::Orientation, + ) -> Result { + let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?; + let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?; + Ok(Self::with_source(ctx, demosaiced, orientation)) + } + + /// Prepare an edit graph over an already-processed RGB image. + /// + /// The JPEG path. A JPEG is already demosaiced, so there is no sensor + /// stage to run — but everything after it is identical, which is why this + /// shares [`Self::with_source`] rather than duplicating the session. + /// + /// Worth being honest about what this cannot recover: an 8-bit JPEG has + /// clipped highlights and quantised shadows that no edit brings back, so + /// exposure has far less latitude here than on sensor data. The controls + /// are the same controls; the file simply carries less to work with. + pub fn open_rgb( + ctx: &GpuContext, + rgba: &[u8], + width: u32, + height: u32, + orientation: dr_types::Orientation, + ) -> Result { + let source = + DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?; + Ok(Self::with_source(ctx, source, orientation)) + } + + pub(super) fn with_source( + ctx: &GpuContext, + demosaiced: DemosaicedImage, + orientation: dr_types::Orientation, + ) -> Self { + let mut graph = EditGraph::default_chain(); + graph.set_orientation(orientation); + let history = History::new(&graph); + Self { + 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, + // Nothing has been looked up, which is not the same as "looked up + // and not found" — `lens_summary` distinguishes them. + lens_profile_found: false, + ctx: ctx.clone(), + graph, + history, + snapshots: Vec::new(), + removed_snapshots: Vec::new(), + compared_snapshot: None, + demosaiced: Arc::new(demosaiced), + adjust: AdjustPass::new(ctx), + histogram: HistogramPass::new(ctx) + .inspect_err(|e| log::warn!("no histogram on this device: {e}")) + .ok(), + raw_histogram: RawHistogramPass::new(ctx) + .inspect_err(|e| log::warn!("no raw histogram on this device: {e}")) + .ok(), + raw_counts: None, + peak: FocusPeakPass::new(ctx) + .inspect_err(|e| log::warn!("no focus peaking on this device: {e}")) + .ok(), + peaking: None, + segmentation: None, + masks: None, + subjects: None, + subject_key: 0, + active_masks: Vec::new(), + active_part: 0, + painting: None, + brush: ( + dr_pipeline::mask::DEFAULT_BRUSH_RADIUS, + dr_pipeline::mask::DEFAULT_BRUSH_HARDNESS, + dr_pipeline::mask::DEFAULT_BRUSH_FLOW, + ), + selected_spot: None, + show_overlay: false, + reveal_style: dr_pipeline::mask::RevealStyle::Tint, + mask_views: std::collections::HashMap::new(), + active_tab: None, + curve_channel: 0, + display_space: dr_types::ColourSpace::Srgb, + auto_crop: None, + } + } + + /// 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) { + // The lens profile is applied *here* rather than by the caller, and + // that is the point of putting it in this method. This is the one + // place a session is told which file it came from, so it is the one + // place the lookup can be made unforgettable — the same shape + // `FilmRebake` uses to stop a derived thing being quietly skipped. + self.apply_lens_profile(&meta); + self.source_meta = Some(meta); + } + + /// TRACES: FR-DEV-3 + /// Look this shot's lens up and hand the coefficients to the corrections. + /// + /// Called with **every** header, including ones naming no lens: the + /// clearing case matters as much as the setting one, because a session + /// reused for a second photograph would otherwise correct it for the + /// optics of the first. + pub(super) fn apply_lens_profile(&mut self, meta: &dr_decode::Metadata) { + let found = Self::profile_for(meta); + self.lens_profile_found = found.is_some(); + self.graph.set_lens_profile(found); + } + + /// The profile for one shot, converted into the pipeline's own types. + /// + /// **The conversion lives here because nowhere else can see both sides.** + /// `dr-lens` carries the Lensfun database and `dr-pipeline` carries the + /// maths, and the coefficient structs are deliberately duplicated so that + /// the dependency between them does not exist (ARCH §6.5a). This function + /// is the seam, and it is a `match` on three optionals. + /// + /// Every field is taken independently. The database routinely knows a + /// lens's distortion and not its vignetting, or covers only part of a + /// zoom's range, and a partial profile is worth applying — discarding it + /// because one field is missing would turn a good correction into none. + pub(crate) fn profile_for(meta: &dr_decode::Metadata) -> Option { + // All three are needed to ask the question at all. A lens name alone + // does not identify a correction: distortion is interpolated across a + // zoom's focal range and vignetting depends strongly on aperture — a + // fast prime can be two stops down in the corners wide open and clean + // by f/8 — so a lookup missing either would return a profile measured + // for a shot nobody took. + let (lens, focal, aperture) = (meta.lens.as_deref()?, meta.focal_length?, meta.aperture?); + + let shot = dr_lens::ShotInfo::new(lens, focal, aperture); + let found = dr_lens::lookup(&shot)?; + if found.is_empty() { + return None; + } + + Some(dr_pipeline::LensProfile { + distortion: found + .distortion + .map(|d| dr_pipeline::ops::distortion::PtLens { + a: d.a, + b: d.b, + c: d.c, + }), + tca: found.tca.map(|t| dr_pipeline::Tca { + red_scale: t.red_scale, + blue_scale: t.blue_scale, + }), + vignetting: found.vignetting.map(|v| dr_pipeline::ops::vignetting::Pa { + k1: v.k1, + k2: v.k2, + k3: v.k3, + }), + }) + } + + /// TRACES: FR-DEV-3 + /// What to tell the photographer about the automatic lens correction. + /// + /// `dr-lens` states the rule this exists to satisfy: an automatic + /// correction that silently did nothing is worse than one the user can see + /// is unavailable. Most lenses in most photographs will not be in the + /// database — third-party glass often reports nothing, adapted manual + /// lenses report nothing at all — so "no profile" is the ordinary case and + /// has to read as a fact rather than as a failure. + pub fn lens_summary(&self) -> String { + let Some(meta) = self.source_meta.as_ref() else { + return String::new(); + }; + let Some(lens) = meta + .lens + .as_deref() + .map(str::trim) + .filter(|l| !l.is_empty()) + else { + // Not "no profile found": nothing was looked up, because the file + // does not say what it was taken with. Naming the wrong reason + // would send someone hunting for a profile that was never missing. + return "Lens not recorded".into(); + }; + match (self.lens_profile_found, self.graph.lens_profile_applied()) { + (true, true) => format!("{lens} · corrected"), + // A profile exists and is switched off, which is neither of the + // other two answers: the photographer turned it off, and a line + // reading "no profile" would send them looking for one that is + // sitting right there in the panel with its box unticked. + (true, false) => format!("{lens} · profile off"), + (false, _) => format!("{lens} · no profile"), + } + } + + /// 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 + /// the aspect ratio, and a viewport sized to the sensor would letterbox a + /// cropped image against the wrong shape. + pub fn source_size(&self) -> (u32, u32) { + let (w, h) = self.demosaiced.size(); + self.graph.output_size(w, h) + } + + /// How many shader pipelines have been compiled. Surfaced so the status + /// strip can show that slider movement is not recompiling. + pub fn compiled_pipelines(&self) -> usize { + self.adjust.cached_pipelines() + } + + pub fn is_neutral(&self) -> bool { + self.graph.is_neutral() + } + + /// TRACES: FR-DEV-6 + /// Lift this session's edit onto the clipboard. + /// + /// Captured at full scope — framing included — because the decision about + /// what travels is made when the preset is *applied*. Copying, then + /// changing one's mind about the crop, must not mean copying again. + /// TRACES: FR-DEV-3 | FR-CAT-8 + /// The local adjustment stack, for writing this image's edit back. + /// + /// Beside `copy_settings` rather than part of it: that returns a `Preset`, + /// which travels *between* photographs, and a mask must not — it is drawn + /// against one frame and describes nothing on another. The save path takes + /// both; the paste path takes only the preset. + pub fn masks(&self) -> &dr_pipeline::mask::MaskStack { + self.graph.masks() + } + + pub fn copy_settings(&self) -> Preset { + Preset::capture(&self.graph) + } + + /// TRACES: FR-DEV-6 + /// Replace this session's edit within `scope`. + /// + /// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves + /// values the sliders are showing, and nothing here pushes them. + pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) { + preset.apply(&mut self.graph, scope); + // A paste is undoable, and is the action most in need of it: it + // replaces everything in scope at once, so getting it wrong costs more + // than any single control can. + self.history + .record(&self.graph, Edit::Action(labels::step::PASTE)); + } + + /// TRACES: FR-CAT-8 + /// Load a stored edit, as read from this image's sidecar. + /// + /// A replacement rather than an overlay — [`Version::apply`] resets first — + /// so a version that stores nothing opens the photograph at its defaults + /// rather than leaving the previous image's exposure standing. The file's + /// orientation survives it, since that was never an edit. + pub fn apply_version(&mut self, version: &dr_pipeline::Version) { + // TRACES: FR-DEV-3f + // The film, which `apply` cleared and could not restore: a sidecar + // names a stock, and turning a name into tables needs the profile + // database that `dr-pipeline` deliberately does not link. So it is + // re-baked here, after the parameters, because the bake reads the + // film's own exposure sliders and they have just arrived. + let rebake = version.apply(&mut self.graph); + self.pay_film_debt(&rebake); + // The stored edit becomes the floor rather than a step. It is not + // something the user did in this sitting, and an undo that reached + // behind it would discard a previous session's work in one press — + // then persist that on the way out, since saving is automatic. + self.history.reset(&self.graph); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::develop::test_support::*; + + /// A lookup needs all three of lens, focal length and aperture. + /// + /// Not pedantry about missing fields: distortion is interpolated across a + /// zoom's focal range and vignetting depends strongly on aperture, so a + /// lookup done without them would return coefficients measured for a shot + /// nobody took and apply them with full confidence. Refusing is the honest + /// answer, and the panel says so. + #[test] + fn a_lookup_needs_the_whole_shot_and_not_just_the_lens() { + let complete = dr_decode::Metadata { + lens: Some("Nikon AF-S 50mm f/1.8G".into()), + focal_length: Some(50.0), + aperture: Some(1.8), + ..Default::default() + }; + + for (name, meta) in [ + ( + "no lens", + dr_decode::Metadata { + lens: None, + ..complete.clone() + }, + ), + ( + "no focal length", + dr_decode::Metadata { + focal_length: None, + ..complete.clone() + }, + ), + ( + "no aperture", + dr_decode::Metadata { + aperture: None, + ..complete.clone() + }, + ), + ] { + assert!( + DevelopSession::profile_for(&meta).is_none(), + "{name}: a partial header must not produce a confident profile" + ); + } + } + + /// "Not recorded" and "no profile" are different facts. + /// + /// Collapsing them would send someone hunting for a missing profile when + /// the file simply never said what took the photograph — and `dr-lens`'s + /// own rule is that the interface must be plain about which it is, because + /// a correction that silently did nothing is worse than one visibly + /// unavailable. + #[test] + fn the_lens_line_says_which_kind_of_nothing_it_found() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); + + let session = |meta: dr_decode::Metadata| { + let mut s = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) + .expect("session"); + s.set_source_metadata(meta); + s + }; + + // A session that was never given a header at all. + let bare = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) + .expect("session"); + assert_eq!(bare.lens_summary(), ""); + + let unrecorded = session(dr_decode::Metadata::default()); + assert_eq!(unrecorded.lens_summary(), "Lens not recorded"); + + // A name no database will match. Deliberately absurd rather than a real + // obscure lens, so the test cannot start passing for the wrong reason + // if the bundled database grows. + let unmatched = session(dr_decode::Metadata { + lens: Some("Nonexistent 999mm f/0.5".into()), + focal_length: Some(999.0), + aperture: Some(0.5), + ..Default::default() + }); + assert_eq!( + unmatched.lens_summary(), + "Nonexistent 999mm f/0.5 · no profile" + ); + assert!( + unmatched.graph.lens_profile().is_none(), + "an unmatched lens must leave the corrections alone" + ); + } + + /// TRACES: FR-DEV-3 + /// A profile switched off is a third answer, and has to read as one. + /// + /// "No profile" sends a photographer looking for a lens the database does + /// not have. If the profile is sitting in the panel with its box unticked, + /// that is a different sentence, and the line has to say which. + /// + /// Depends on the bundled database holding a common lens, exactly as + /// `dr_lens`'s own tests do — this is the only path that sets + /// `lens_profile_found`, and standing a profile up by hand would test the + /// formatting while skipping the lookup it is reporting on. + #[test] + fn the_lens_line_separates_a_declined_profile_from_a_missing_one() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) + .expect("session"); + + const LENS: &str = "Canon EF 16-35mm f/2.8L USM"; + session.set_source_metadata(dr_decode::Metadata { + lens: Some(LENS.into()), + focal_length: Some(20.0), + aperture: Some(2.8), + ..Default::default() + }); + assert_eq!(session.lens_summary(), format!("{LENS} · corrected")); + + session.graph.set_lens_profile_applied(false); + assert_eq!(session.lens_summary(), format!("{LENS} · profile off")); + + session.graph.set_lens_profile_applied(true); + assert_eq!(session.lens_summary(), format!("{LENS} · corrected")); + } + + /// Opening a second photograph must not correct it for the first one's lens. + /// + /// The clearing case, and the reason `apply_lens_profile` runs on every + /// header rather than only on the ones that match something. A stale + /// profile is invisible: the picture is simply wrong in a way that looks + /// like the lens. + #[test] + fn a_second_photograph_does_not_inherit_the_first_lens_profile() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) + .expect("session"); + + // Stand in for a matched lens by applying a profile directly, so the + // test does not depend on what the bundled database happens to hold. + session + .graph + .set_lens_profile(Some(dr_pipeline::LensProfile { + distortion: Some(dr_pipeline::ops::distortion::PtLens { + a: 0.0, + b: -0.02, + c: 0.0, + }), + tca: None, + vignetting: None, + })); + assert!(session.graph.lens_profile().is_some()); + + session.set_source_metadata(dr_decode::Metadata::default()); + + assert!( + session.graph.lens_profile().is_none(), + "a header naming no lens must clear the previous photograph's \ + correction, not leave it standing" + ); + } +} diff --git a/ui/dr-ui/src/develop/tabs.rs b/ui/dr-ui/src/develop/tabs.rs new file mode 100644 index 0000000..6cf3ba9 --- /dev/null +++ b/ui/dr-ui/src/develop/tabs.rs @@ -0,0 +1,284 @@ +//! The attribute tab strip (ARCH §4.3a, FR-DEV-3a): which tab is active and +//! which capabilities it narrows the panel to. +#[cfg(test)] +use dr_gpu::GpuContext; +use dr_pipeline::mask::MaskLayer; +use dr_pipeline::OpCapability; + +use crate::ParamRow; + +use super::rows::rows_filtered; +use super::session::DevelopSession; + +impl DevelopSession { + /// The controls the interface should show. + /// + /// Built entirely from the capability list. The `kind` string chooses the + /// widget; nothing switches on a parameter's identity. + pub fn rows(&self) -> Vec { + let caps = self.scoped_capabilities(); + match self.active_tab { + Some(attribute) => rows_filtered( + &caps, + |op| op.attributes.contains(&attribute), + self.curve_channel, + ), + None => rows_filtered(&caps, |_| true, self.curve_channel), + } + } + + /// The capability list the panel is currently describing. + /// + /// A selected mask layer takes over the panel, so every control the global + /// chain offers is offered on a layer too — including operations added + /// later, which need no work to become local. + pub(super) fn scoped_capabilities(&self) -> Vec { + match self.active_layer() { + Some(layer) => layer.capabilities(), + None => self.graph.capabilities(), + } + } + + /// The attributes worth offering as tabs, in declaration order. + /// + /// **Derived from the chain, never listed here.** The groups are whatever + /// the operations say they are about, so a new operation joins the right + /// tab by declaring its nature and this file goes on naming none of them + /// (FR-DEV-3a). An attribute nothing carries is left out rather than + /// offered as a tab that opens onto nothing. + /// + /// Compose is excluded: its one operation prefers an on-canvas widget and + /// is skipped by the row builder, so a Compose tab would be empty of rows + /// while `ComposePanel` holds the real controls. + pub fn tabs(&self) -> Vec<(dr_pipeline::Attribute, String)> { + use dr_pipeline::Attribute; + let caps = self.scoped_capabilities(); + Attribute::ALL + .into_iter() + .filter(|a| *a != Attribute::Compose) + .filter(|a| { + caps.iter().any(|c| { + c.attributes.contains(a) + && !rows_filtered(&caps, |o| o.attributes.contains(a), 0).is_empty() + }) + }) + .map(|a| (a, crate::labels::resolve(a.label().0))) + .collect() + } + + /// Which tab is selected, as an index into [`Self::tabs`]. `-1` is "all". + pub fn active_tab(&self) -> i32 { + let Some(active) = self.active_tab else { + return -1; + }; + self.tabs() + .iter() + .position(|(a, _)| *a == active) + .map_or(-1, |i| i as i32) + } + + /// TRACES: FR-DEV-3f + /// Whether the film stock belongs in the group currently on screen. + /// + /// The stock is not a parameter, so it is not a [`ParamRow`] and the tab + /// filter that hides every other control never reached it: the picker was + /// drawn above the rows in *every* group, so "Kodachrome" sat at the top of + /// Light, of Colour and of Detail alike. Three places it does not belong, + /// and the one it does was no more prominent than the rest. + /// + /// Answered here rather than in the panel because it is a question about + /// the operation — what is this control *about* — and the panel is not + /// allowed to know. It asks the descriptor, so a stock that were ever + /// re-declared as something other than an effect would move on its own. + pub fn film_in_group(&self) -> bool { + let Some(active) = self.active_tab else { + // "All" shows everything, the stock included. + return true; + }; + self.graph + .capabilities() + .iter() + .find(|c| c.id == dr_pipeline::ops::film_sim::ID) + .is_some_and(|c| c.attributes.contains(&active)) + } + + /// Select a tab by its index in [`Self::tabs`], or `-1` for all. + pub fn set_active_tab(&mut self, index: i32) { + self.active_tab = usize::try_from(index) + .ok() + .and_then(|i| self.tabs().get(i).map(|(a, _)| *a)); + } + + /// The selection's representative layer, for anything that can only show + /// one answer — which tab is open, what value a slider currently reads. + /// + /// The first id selected, not "the" active layer: with more than one + /// selected there is no single truth to show, and the panel has to pick + /// something. Whichever layer this is, [`Self::set_param`] and + /// [`Self::reset_op`] still write to every selected layer, not just this + /// one — a slider shows one number and applies it everywhere selected. + pub(super) fn active_layer(&self) -> Option<&MaskLayer> { + let id = self.active_masks.first()?; + self.graph.masks().get(id) + } + + /// Every selected layer, mutably — what a batched slider or reset walks. + pub(super) fn active_layers_mut(&mut self) -> impl Iterator { + let selected = self.active_masks.clone(); + self.graph + .masks_mut() + .layers_mut() + .iter_mut() + .filter(move |l| selected.contains(&l.id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::develop::test_support::*; + + /// Every attribute the chain actually carries must reach the tab strip. + /// + /// The strip is generated, so an attribute with operations behind it and + /// no tab in front of it is unreachable — the controls exist, are in the + /// shader, and cannot be filtered to. That is exactly how `Optics` sat + /// invisible for as long as it had no operations, and the failure looks + /// identical from the outside whether the cause is an empty category or a + /// broken filter. + #[test] + fn every_attribute_with_operations_gets_a_tab() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); + let session = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) + .expect("session"); + + let tabs: Vec = + session.tabs().into_iter().map(|(a, _)| a).collect(); + + for attribute in dr_pipeline::Attribute::ALL { + // Compose is deliberately absent: its one stage prefers an + // on-canvas widget, so a Compose tab would open onto nothing while + // `ComposePanel` holds the real controls. + if attribute == dr_pipeline::Attribute::Compose { + continue; + } + let carried = dr_pipeline::EditGraph::default_chain() + .capabilities() + .iter() + .any(|c| c.attributes.contains(&attribute) && !c.params.is_empty()); + assert_eq!( + tabs.contains(&attribute), + carried, + "{attribute:?}: carried by the chain = {carried}, has a tab = {}", + tabs.contains(&attribute) + ); + } + } + + // ---------------------------------------------------------------------- + // Group tabs (ARCH §4.3a, FR-DEV-3a) + // ---------------------------------------------------------------------- + + fn tabbed_session(ctx: &GpuContext) -> DevelopSession { + let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); + DevelopSession::open_rgb(ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) + .expect("session") + } + + #[test] + fn the_tabs_come_from_the_chain_not_from_a_list() { + let Some(ctx) = headless() else { return }; + let session = tabbed_session(&ctx); + + let names: Vec = session.tabs().into_iter().map(|(_, n)| n).collect(); + assert!(names.contains(&"Light".to_string()), "got {names:?}"); + assert!(names.contains(&"Colour".to_string()), "got {names:?}"); + // Nothing offers a group with no rows behind it. + for (attribute, name) in session.tabs() { + let mut probe = tabbed_session(&ctx); + let index = probe + .tabs() + .iter() + .position(|(a, _)| *a == attribute) + .expect("just listed"); + probe.set_active_tab(index as i32); + assert!(!probe.rows().is_empty(), "tab {name} opens onto nothing"); + } + } + + #[test] + fn choosing_a_tab_narrows_the_panel() { + let Some(ctx) = headless() else { return }; + let mut session = tabbed_session(&ctx); + + let all = session.rows().len(); + session.set_active_tab(0); + let narrowed = session.rows().len(); + + assert!(narrowed > 0, "a tab must show something"); + assert!( + narrowed < all, + "and less than everything: {narrowed} of {all}" + ); + } + + /// The trap: `op_index` counts over *every* capability, so a row that + /// survived a filter must still route to the operation it came from. If it + /// renumbered, a slider would drive a different operation once a tab was + /// chosen. + #[test] + fn a_filtered_row_still_drives_its_own_operation() { + let Some(ctx) = headless() else { return }; + let mut session = tabbed_session(&ctx); + + // Find a colour row while unfiltered, and remember where it points. + let colour = session + .tabs() + .iter() + .position(|(_, n)| n == "Colour") + .expect("the chain has colour operations"); + session.set_active_tab(colour as i32); + + let row = session.rows().into_iter().next().expect("a row"); + let (op, param) = (row.op_index, row.param_index); + + session.set_param(op, param, 0.5); + let after = session + .rows() + .into_iter() + .find(|r| r.op_index == op && r.param_index == param) + .expect("the row survived"); + + assert!( + (after.value - 0.5).abs() < 1e-5, + "the value landed on the row that asked for it, not another" + ); + } + + #[test] + fn all_is_reachable_again() { + let Some(ctx) = headless() else { return }; + let mut session = tabbed_session(&ctx); + let all = session.rows().len(); + + session.set_active_tab(0); + assert!(session.rows().len() < all); + + session.set_active_tab(-1); + assert_eq!(session.rows().len(), all, "-1 means everything"); + assert_eq!(session.active_tab(), -1); + } + + /// An out-of-range index is navigation nonsense, not an edit; it must not + /// leave the panel showing nothing. + #[test] + fn a_nonsense_tab_falls_back_to_everything() { + let Some(ctx) = headless() else { return }; + let mut session = tabbed_session(&ctx); + let all = session.rows().len(); + + session.set_active_tab(99); + assert_eq!(session.rows().len(), all); + } +} diff --git a/ui/dr-ui/src/develop/white_balance.rs b/ui/dr-ui/src/develop/white_balance.rs new file mode 100644 index 0000000..1f5f232 --- /dev/null +++ b/ui/dr-ui/src/develop/white_balance.rs @@ -0,0 +1,383 @@ +//! Setting the white balance from a point on the photograph. +#[cfg(test)] +use dr_decode::RawImage; +use dr_pipeline::Edit; + +use crate::labels; + +use super::session::DevelopSession; + +impl DevelopSession { + /// TRACES: FR-DEV-3 | FR-DEV-5 + /// Set the white balance from a point on the photograph. + /// + /// `x` and `y` are fractions of the *visible* image — the coordinates a + /// click on the canvas arrives in — so a photographer inspecting a + /// highlight at 4× samples the pixel they are actually looking at. + /// + /// **Nothing here knows it is white balance.** The colour goes to + /// [`dr_pipeline::neutral`], which finds the operation that asked to be + /// driven by a pixel and inverts its declared response; this side supplies + /// the pixel and the undo step and nothing else. That is FR-DEV-3a's line + /// in its awkward case: a picker genuinely needs to know how far a hundred + /// units of temperature move red against blue, and that number is declared + /// in the node's own file, so the interface must not be the thing that + /// holds a second copy of it. + /// + /// **One step per sample**, and no step at all for a sample that could not + /// be used — a point in the deep shadows has no balance in it to correct. + /// `Edit::Action` never coalesces, so two clicks are two decisions + /// however quickly they follow each other, which is what a photographer + /// trying a wall and then a cloud expects to be able to undo one at a + /// time. + /// + /// Returns whether the photograph moved. + pub fn sample_neutral(&mut self, x: f32, y: f32) -> bool { + let Some(sample) = self.sample_as_shot(x, y) else { + return false; + }; + if !dr_pipeline::neutral::neutralise(&mut self.graph, sample) { + return false; + } + self.history + .record(&self.graph, Edit::Action(labels::step::SAMPLED_NEUTRAL)); + true + } + + /// The colour at a point in the space the white balance gains multiply: + /// camera RGB with the camera's own balance on, linear, nothing else. + /// + /// **Measured where the operation acts, not where the photographer + /// looks.** The white balance node runs first in the chain, on camera + /// RGB, before the body's base curve and its matrix; the canvas shows + /// the pixel after all three. The probe used to be read off a display + /// render with the adjustments stripped, and the solve then treated an + /// sRGB triple as if the gains multiplied it directly. On a JPEG the two + /// spaces coincide, so it worked; on a raw file from any real body the + /// matrix mixes the channels, and a slightly blue wall on a Canon 6D + /// came back tint −77 with the whole frame green. This reads the + /// camera-space tap a merge stitches from — the sensor's numbers after + /// the lens warp — and puts the as-shot balance on itself, which is + /// exactly the value the operation's gains are about to multiply. + /// + /// That also means nothing has to be stripped and restored: the tap + /// runs no operations at all, and the display target is untouched, so + /// a sample that found nothing usable leaves the canvas exactly as it + /// was. + /// + /// The framing is the edit's own, exactly as for [`Self::render_original`] + /// and for the same reason: `x` and `y` are fractions of what is on + /// screen, and a probe rendered without the crop and the zoom would be + /// answering about a different part of the photograph. + /// + /// **A patch, not a point.** The shader fetches the source at one + /// position per output pixel — nearest, or four photosites blended — so + /// a probe of the whole visible region rendered at 192px was not + /// "averaging a neighbourhood into each pixel" as its comment claimed; + /// it was one point sample of a noisy sensor, and two painted-white air + /// conditioners on the same wall answered +37 and −50. Every eyedropper + /// averages for exactly this reason: the photographer is pointing at a + /// grey card, not at a photosite. So the tap is narrowed to the + /// [`PATCH`] of the canvas around the click — a couple of percent of + /// its width, square on screen — and rendered at [`PROBE_PX`] square + /// with interpolation on, which puts a sample on every sensor pixel + /// under the patch at any ordinary zoom. Those are averaged; a sample + /// the tap marked void (outside the frame after the lens correction) or + /// clipped is left out rather than allowed to pull the mean, and if + /// fewer than half the patch survives there was nothing there to + /// balance against. One small dispatch and a 64 KB readback on a click. + pub(super) fn sample_as_shot(&mut self, x: f32, y: f32) -> Option<[f32; 3]> { + /// Width of the patch as a fraction of what is on the canvas. + const PATCH: f32 = 0.015; + /// Side of the probe render, in pixels. + const PROBE_PX: u32 = 64; + + // Square on screen: the height fraction follows the aspect of the + // visible region, which is the crop's shape times the view's. + let (sw, sh) = self.demosaiced.size(); + let (cw, ch) = self.graph.output_size(sw, sh); + let view = self.graph.framing().view(); + let aspect = (cw as f32 * view.width) / (ch as f32 * view.height).max(f32::EPSILON); + let (pw, ph) = (PATCH, PATCH * aspect); + let patch = dr_pipeline::CropRect { + x: x.clamp(0.0, 1.0) - pw * 0.5, + y: y.clamp(0.0, 1.0) - ph * 0.5, + width: pw, + height: ph, + }; + + let shader = self.graph.compose_camera_probe(patch); + let rendered = self + .adjust + .render_camera_linear(&self.demosaiced, &shader, PROBE_PX, PROBE_PX) + .map(|_| ()); + let (rgba, _, _) = rendered + .and_then(|()| self.adjust.read_camera_linear()) + .inspect_err(|e| log::warn!("could not read a neutral off the frame: {e}")) + .ok()?; + + let mut sum = [0.0f32; 3]; + let mut kept = 0usize; + let mut seen = 0usize; + for pixel in rgba.chunks_exact(4) { + seen += 1; + // The tap marks a pixel the lens correction pulled in from + // outside the frame with alpha 0. There is nothing there to + // balance against. + if pixel[3] < 0.5 { + continue; + } + // Nor in a clipped one. A blown sky reads as sensor white, and + // sensor white with the as-shot balance on is strongly magenta — + // a solve over it drives tint to its stop for a pixel that, on + // the canvas, the shader has already desaturated to neutral. The + // same threshold the shader fades from, so what is refused here + // is what it would have hidden there. + if pixel[..3].iter().any(|c| *c >= dr_pipeline::CLIP_ONSET) { + continue; + } + for (acc, c) in sum.iter_mut().zip(pixel) { + *acc += c; + } + kept += 1; + } + if kept == 0 || kept * 2 < seen { + return None; + } + + // The tap is the sensor's numbers with the profile filled neutral; + // the operation multiplies them *after* the camera's own balance, so + // that goes on here and the solve sees what the gains will see. + let wb = self.demosaiced.as_shot_wb(); + let n = kept as f32; + Some([sum[0] / n * wb[0], sum[1] / n * wb[1], sum[2] / n * wb[2]]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::develop::test_support::*; + + /// TRACES: FR-DEV-3 | FR-DEV-5 + /// Sampling something that is already neutral corrects nothing, and says + /// so by leaving the stack alone. + /// + /// The failure this guards is a picker that lands a ten-thousandth off + /// zero: the photograph would come back marked modified, an undo step + /// would appear for a correction of nothing, and the sidecar would gain a + /// temperature the photographer never chose. The solve rounds to the + /// precision the control is drawn at, which is what makes "no correction" + /// representable at all. + #[test] + fn sampling_a_grey_that_is_already_grey_leaves_the_photograph_alone() { + let Some(ctx) = headless() else { return }; + let (mut session, _) = grey_session(&ctx); + let steps = session.history_rows().len(); + + assert!( + session.sample_neutral(0.5, 0.5), + "a flat grey frame is a usable sample" + ); + assert!( + session.is_neutral(), + "there was nothing to correct, so nothing was corrected" + ); + assert_eq!( + session.history_rows().len(), + steps, + "and a correction of nothing is not a step" + ); + } + + /// TRACES: FR-DEV-3 + /// The whole point of the picker, measured where the photographer sees + /// it: a cast grey on a *raw* frame, sampled, renders grey. + /// + /// On a raw frame and not a JPEG, because that is where it was wrong. The + /// white balance gains multiply camera RGB, before the body's matrix + /// turns it into sRGB; the probe was read *after* the matrix, and the + /// solve treated the two as the same space. On a body whose matrix mixes + /// the channels as much as a Canon's does, a slightly blue wall came back + /// tint −77 and the whole frame went green. A JPEG carries an identity + /// matrix, so the same test on one passed while the picker was broken. + #[test] + fn sampling_a_cast_grey_on_a_raw_frame_renders_it_grey() { + let Some(ctx) = headless() else { return }; + + // A Canon EOS 6D's D65 matrix (rows summing to one, as + // `neutral_stays_neutral_through_the_colour_matrix` requires) and a + // typical as-shot balance for it. + let cam_to_srgb = [ + 1.9125, -1.0587, 0.1461, // + -0.2249, 1.6466, -0.4217, // + 0.0099, -0.5093, 1.4994, + ]; + let as_shot = [1.9, 1.0, 1.7]; + // What the wall should look like once the camera's own balance is on: + // a warm cast, a little over half a stop between red and blue. + let balanced = [0.30f32, 0.25, 0.20]; + let sensor: Vec = (0..3) + .map(|c| (balanced[c] / as_shot[c] * 65535.0).round() as u16) + .collect(); + let size = 64u32; + let raw = RawImage { + width: size, + height: size, + data: sensor.repeat((size * size) as usize), + cfa_pattern: dr_decode::CfaPattern::Rggb, + black_level: [0; 4], + white_level: 65535, + wb_coeffs: [as_shot[0], as_shot[1], as_shot[2], 0.0], + color_matrix: Some(cam_to_srgb), + base_curve: dr_decode::BaseCurve::IDENTITY, + samples_per_pixel: 3, + profile: None, + make: String::new(), + model: String::new(), + crop: dr_decode::CropRect { + x: 0, + y: 0, + width: size, + height: size, + }, + }; + let mut session = + DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); + + let at = ((size / 2) * size + size / 2) as usize * 4; + let before = read_back(&ctx, &session.render(size, size).expect("render")); + let cast = |px: &[u8]| px.iter().max().unwrap() - px.iter().min().unwrap(); + assert!( + cast(&before[at..at + 3]) > 20, + "the premise: the wall renders with a cast, {:?}", + &before[at..at + 3] + ); + + assert!( + session.sample_neutral(0.5, 0.5), + "a mid-grey is a usable sample" + ); + + let after = read_back(&ctx, &session.render(size, size).expect("render")); + let px = &after[at..at + 3]; + assert!( + cast(px) <= 3, + "the sampled point should render neutral, got {px:?} with {:?}", + session + .rows() + .iter() + .filter(|r| r.value != r.default_value) + .map(|r| (r.param_label.to_string(), r.value)) + .collect::>() + ); + } + + /// TRACES: FR-DEV-3 + /// The picker reads a patch, not a photosite. + /// + /// A frame whose pixels alternate warm and cool grey, averaging to a + /// neutral: a point sample lands on one or the other and swings the + /// controls hard one way, which is what two white boxes on the same wall + /// answering +37 and −50 looked like. Averaged, there is nothing to + /// correct, and the graph says so. + #[test] + fn sampling_averages_a_patch_rather_than_reading_one_photosite() { + let Some(ctx) = headless() else { return }; + // Large enough that the patch — a couple of percent of the frame — + // holds many sensor pixels; on a 64px frame it would hold one, and + // the test would be asserting about interpolation instead. + let size = 1536u32; + let warm = [0.30f32, 0.25, 0.20]; + let cool = [0.20f32, 0.25, 0.30]; + let mut data = Vec::with_capacity((size * size * 3) as usize); + for i in 0..(size * size) as usize { + let p = if i % 2 == 0 { warm } else { cool }; + data.extend(p.iter().map(|c| (c * 65535.0).round() as u16)); + } + let raw = RawImage { + width: size, + height: size, + data, + cfa_pattern: dr_decode::CfaPattern::Rggb, + black_level: [0; 4], + white_level: 65535, + wb_coeffs: [1.0, 1.0, 1.0, 0.0], + color_matrix: None, + base_curve: dr_decode::BaseCurve::IDENTITY, + samples_per_pixel: 3, + profile: None, + make: String::new(), + model: String::new(), + crop: dr_decode::CropRect { + x: 0, + y: 0, + width: size, + height: size, + }, + }; + let mut session = + DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); + + assert!( + session.sample_neutral(0.5, 0.5), + "a mid-grey patch is usable" + ); + let moved: Vec<_> = session + .rows() + .iter() + .filter(|r| r.value != r.default_value) + .map(|r| (r.param_label.to_string(), r.value)) + .collect(); + assert!( + moved.iter().all(|(_, v)| v.abs() <= 2.0), + "the patch averages neutral, so nothing should move far: {moved:?}" + ); + } + + /// TRACES: FR-DEV-3 + /// A blown highlight is refused, the way black is. + /// + /// Sensor white is not a colour: every channel stopped counting, so the + /// ratio between them is the as-shot multipliers and nothing about the + /// scene. Sampling the overcast sky on a Canon 6D frame drove tint to + /// -100 and temperature to -15 for a patch the canvas showed as pure + /// white, which is the picker being wrong rather than the point being a + /// poor choice. Refused, nothing moves and no step is taken. + #[test] + fn sampling_a_blown_highlight_moves_nothing() { + let Some(ctx) = headless() else { return }; + let size = 16u32; + let raw = RawImage { + width: size, + height: size, + data: vec![65535; (size * size * 3) as usize], + cfa_pattern: dr_decode::CfaPattern::Rggb, + black_level: [0; 4], + white_level: 65535, + wb_coeffs: [1.9, 1.0, 1.7, 0.0], + color_matrix: None, + base_curve: dr_decode::BaseCurve::IDENTITY, + samples_per_pixel: 3, + profile: None, + make: String::new(), + model: String::new(), + crop: dr_decode::CropRect { + x: 0, + y: 0, + width: size, + height: size, + }, + }; + let mut session = + DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); + let steps = session.history_rows().len(); + + assert!( + !session.sample_neutral(0.5, 0.5), + "a clipped photosite has no balance in it" + ); + assert!(session.is_neutral(), "and so nothing was corrected"); + assert_eq!(session.history_rows().len(), steps); + } +}