Merge the batch export, and settle the seven-branch merge
Resolves the last of the parallel work. Two conflicts worth recording, because both were semantic rather than textual: `render_for_export` gained a colour space on master while the batch branch was rewriting the single-image export path around it. Kept both: the batch request supersedes the synchronous path, and the space still has to be chosen at render time because the conversion happens in the shader before the clip to 0..1. `render_open_frame` takes it as an argument rather than reaching for a controller it does not hold. The map-wait moved into `readback::await_mapping` on one branch while another was editing the constant it used, so `READBACK_POLL_LIMIT` survived the merge with no callers. Removed rather than left for clippy to find later. 1164 tests pass, clippy clean, fmt clean. Traceability 53.0% -> 54.3%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,18 +32,6 @@ use crate::{DemosaicedImage, GpuContext, GpuError};
|
||||
/// reads them.
|
||||
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
|
||||
|
||||
/// How many non-blocking polls a readback gets before it is called failed.
|
||||
///
|
||||
/// A bound rather than a spin forever: if the device is lost the map callback
|
||||
/// never arrives, and an unbounded loop would hang the interface rather than
|
||||
/// surfacing the error. Set far above any plausible completion — the copy this
|
||||
/// waits on is milliseconds — so it is reached only when something is wrong.
|
||||
///
|
||||
/// Ungated along with [`AdjustPass::export_pixels`], the one readback that
|
||||
/// survives S1: an export reads pixels back in a shipping build, and a lost
|
||||
/// device mid-export must surface as an error rather than a hung interface.
|
||||
const READBACK_POLL_LIMIT: u32 = 100_000;
|
||||
|
||||
/// Runs composed operation chains against demosaiced images.
|
||||
pub struct AdjustPass {
|
||||
ctx: GpuContext,
|
||||
|
||||
+33
-33
@@ -9,18 +9,18 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Source files scanned | 124 |
|
||||
| TRACES tags found | 229 |
|
||||
| Source files scanned | 129 |
|
||||
| TRACES tags found | 278 |
|
||||
| Requirements defined | 151 |
|
||||
| Requirements covered | 80 |
|
||||
| **Coverage** | **53.0%** (80/151) |
|
||||
| Requirements covered | 82 |
|
||||
| **Coverage** | **54.3%** (82/151) |
|
||||
|
||||
### By type
|
||||
|
||||
| Type | Covered | Defined |
|
||||
|---|---|---|
|
||||
| FR | 63 | 97 |
|
||||
| NFR | 15 | 48 |
|
||||
| FR | 64 | 97 |
|
||||
| NFR | 16 | 48 |
|
||||
| R | 2 | 6 |
|
||||
|
||||
## Orphan tags
|
||||
@@ -36,7 +36,7 @@ _None._
|
||||
| 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:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196), [`core/dr-types/src/lib.rs:265`](../core/dr-types/src/lib.rs#L265), [`core/dr-types/src/lib.rs:298`](../core/dr-types/src/lib.rs#L298), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`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-11 | [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152) |
|
||||
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108) |
|
||||
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:258`](../core/dr-catalog/src/schema.rs#L258), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:366`](../core/dr-sync-nextcloud/src/lib.rs#L366), [`core/dr-sync/src/lib.rs:122`](../core/dr-sync/src/lib.rs#L122), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1299`](../ui/dr-ui/src/collections_ui.rs#L1299), [`ui/dr-ui/src/collections_ui.rs:817`](../ui/dr-ui/src/collections_ui.rs#L817), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2428`](../ui/dr-ui/src/library.rs#L2428), [`ui/dr-ui/src/library.rs:2460`](../ui/dr-ui/src/library.rs#L2460), [`ui/dr-ui/src/library_ui.rs:112`](../ui/dr-ui/src/library_ui.rs#L112), [`ui/dr-ui/src/library_ui.rs:508`](../ui/dr-ui/src/library_ui.rs#L508), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:458`](../ui/dr-ui/ui/collections.slint#L458) |
|
||||
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:258`](../core/dr-catalog/src/schema.rs#L258), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:366`](../core/dr-sync-nextcloud/src/lib.rs#L366), [`core/dr-sync/src/lib.rs:122`](../core/dr-sync/src/lib.rs#L122), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1299`](../ui/dr-ui/src/collections_ui.rs#L1299), [`ui/dr-ui/src/collections_ui.rs:817`](../ui/dr-ui/src/collections_ui.rs#L817), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2428`](../ui/dr-ui/src/library.rs#L2428), [`ui/dr-ui/src/library.rs:2460`](../ui/dr-ui/src/library.rs#L2460), [`ui/dr-ui/src/library_ui.rs:112`](../ui/dr-ui/src/library_ui.rs#L112), [`ui/dr-ui/src/library_ui.rs:592`](../ui/dr-ui/src/library_ui.rs#L592), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:458`](../ui/dr-ui/ui/collections.slint#L458) |
|
||||
| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:49`](../core/dr-types/src/lib.rs#L49) |
|
||||
| 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), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
||||
| 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:69`](../core/dr-sync/src/scan.rs#L69), [`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) |
|
||||
@@ -44,79 +44,81 @@ _None._
|
||||
| FR-CAT-5 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-decode/src/lib.rs:240`](../core/dr-decode/src/lib.rs#L240), [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125) |
|
||||
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.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-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179) |
|
||||
| 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:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||
| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/develop.rs:908`](../ui/dr-ui/src/develop.rs#L908), [`ui/dr-ui/src/lib.rs:1250`](../ui/dr-ui/src/lib.rs#L1250), [`ui/dr-ui/src/lib.rs:1355`](../ui/dr-ui/src/lib.rs#L1355), [`ui/dr-ui/src/lib.rs:411`](../ui/dr-ui/src/lib.rs#L411), [`ui/dr-ui/src/lib.rs:613`](../ui/dr-ui/src/lib.rs#L613), [`ui/dr-ui/src/lib.rs:942`](../ui/dr-ui/src/lib.rs#L942), [`ui/dr-ui/src/library.rs:1327`](../ui/dr-ui/src/library.rs#L1327), [`ui/dr-ui/src/library.rs:291`](../ui/dr-ui/src/library.rs#L291), [`ui/dr-ui/src/library.rs:349`](../ui/dr-ui/src/library.rs#L349), [`ui/dr-ui/src/library.rs:565`](../ui/dr-ui/src/library.rs#L565), [`ui/dr-ui/src/library_ui.rs:3136`](../ui/dr-ui/src/library_ui.rs#L3136), [`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:230`](../core/dr-catalog/src/schema.rs#L230), [`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:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:115`](../core/dr-types/src/lib.rs#L115), [`ui/dr-ui/src/library.rs:1287`](../ui/dr-ui/src/library.rs#L1287), [`ui/dr-ui/src/library.rs:1364`](../ui/dr-ui/src/library.rs#L1364), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2548`](../ui/dr-ui/src/library.rs#L2548), [`ui/dr-ui/src/library.rs:349`](../ui/dr-ui/src/library.rs#L349), [`ui/dr-ui/src/library.rs:549`](../ui/dr-ui/src/library.rs#L549), [`ui/dr-ui/src/library.rs:565`](../ui/dr-ui/src/library.rs#L565), [`ui/dr-ui/src/library.rs:619`](../ui/dr-ui/src/library.rs#L619), [`ui/dr-ui/src/library_ui.rs:1046`](../ui/dr-ui/src/library_ui.rs#L1046), [`ui/dr-ui/src/library_ui.rs:1072`](../ui/dr-ui/src/library_ui.rs#L1072), [`ui/dr-ui/src/library_ui.rs:1088`](../ui/dr-ui/src/library_ui.rs#L1088), [`ui/dr-ui/src/library_ui.rs:1182`](../ui/dr-ui/src/library_ui.rs#L1182), [`ui/dr-ui/src/library_ui.rs:1444`](../ui/dr-ui/src/library_ui.rs#L1444), [`ui/dr-ui/src/library_ui.rs:146`](../ui/dr-ui/src/library_ui.rs#L146), [`ui/dr-ui/src/library_ui.rs:1709`](../ui/dr-ui/src/library_ui.rs#L1709), [`ui/dr-ui/src/library_ui.rs:179`](../ui/dr-ui/src/library_ui.rs#L179), [`ui/dr-ui/src/library_ui.rs:2043`](../ui/dr-ui/src/library_ui.rs#L2043), [`ui/dr-ui/src/library_ui.rs:2115`](../ui/dr-ui/src/library_ui.rs#L2115), [`ui/dr-ui/src/library_ui.rs:2280`](../ui/dr-ui/src/library_ui.rs#L2280), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:3288`](../ui/dr-ui/src/library_ui.rs#L3288), [`ui/dr-ui/src/library_ui.rs:3316`](../ui/dr-ui/src/library_ui.rs#L3316), [`ui/dr-ui/src/library_ui.rs:365`](../ui/dr-ui/src/library_ui.rs#L365), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/develop.rs:962`](../ui/dr-ui/src/develop.rs#L962), [`ui/dr-ui/src/export.rs:697`](../ui/dr-ui/src/export.rs#L697), [`ui/dr-ui/src/lib.rs:1332`](../ui/dr-ui/src/lib.rs#L1332), [`ui/dr-ui/src/lib.rs:1437`](../ui/dr-ui/src/lib.rs#L1437), [`ui/dr-ui/src/lib.rs:412`](../ui/dr-ui/src/lib.rs#L412), [`ui/dr-ui/src/lib.rs:686`](../ui/dr-ui/src/lib.rs#L686), [`ui/dr-ui/src/lib.rs:999`](../ui/dr-ui/src/lib.rs#L999), [`ui/dr-ui/src/library.rs:1327`](../ui/dr-ui/src/library.rs#L1327), [`ui/dr-ui/src/library.rs:291`](../ui/dr-ui/src/library.rs#L291), [`ui/dr-ui/src/library.rs:349`](../ui/dr-ui/src/library.rs#L349), [`ui/dr-ui/src/library.rs:565`](../ui/dr-ui/src/library.rs#L565), [`ui/dr-ui/src/library_ui.rs:3225`](../ui/dr-ui/src/library_ui.rs#L3225), [`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:230`](../core/dr-catalog/src/schema.rs#L230), [`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:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:115`](../core/dr-types/src/lib.rs#L115), [`ui/dr-ui/src/library.rs:1287`](../ui/dr-ui/src/library.rs#L1287), [`ui/dr-ui/src/library.rs:1364`](../ui/dr-ui/src/library.rs#L1364), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2548`](../ui/dr-ui/src/library.rs#L2548), [`ui/dr-ui/src/library.rs:349`](../ui/dr-ui/src/library.rs#L349), [`ui/dr-ui/src/library.rs:549`](../ui/dr-ui/src/library.rs#L549), [`ui/dr-ui/src/library.rs:565`](../ui/dr-ui/src/library.rs#L565), [`ui/dr-ui/src/library.rs:619`](../ui/dr-ui/src/library.rs#L619), [`ui/dr-ui/src/library_ui.rs:1130`](../ui/dr-ui/src/library_ui.rs#L1130), [`ui/dr-ui/src/library_ui.rs:1156`](../ui/dr-ui/src/library_ui.rs#L1156), [`ui/dr-ui/src/library_ui.rs:1172`](../ui/dr-ui/src/library_ui.rs#L1172), [`ui/dr-ui/src/library_ui.rs:1266`](../ui/dr-ui/src/library_ui.rs#L1266), [`ui/dr-ui/src/library_ui.rs:146`](../ui/dr-ui/src/library_ui.rs#L146), [`ui/dr-ui/src/library_ui.rs:1528`](../ui/dr-ui/src/library_ui.rs#L1528), [`ui/dr-ui/src/library_ui.rs:1793`](../ui/dr-ui/src/library_ui.rs#L1793), [`ui/dr-ui/src/library_ui.rs:179`](../ui/dr-ui/src/library_ui.rs#L179), [`ui/dr-ui/src/library_ui.rs:2127`](../ui/dr-ui/src/library_ui.rs#L2127), [`ui/dr-ui/src/library_ui.rs:2199`](../ui/dr-ui/src/library_ui.rs#L2199), [`ui/dr-ui/src/library_ui.rs:2364`](../ui/dr-ui/src/library_ui.rs#L2364), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:3377`](../ui/dr-ui/src/library_ui.rs#L3377), [`ui/dr-ui/src/library_ui.rs:3405`](../ui/dr-ui/src/library_ui.rs#L3405), [`ui/dr-ui/src/library_ui.rs:365`](../ui/dr-ui/src/library_ui.rs#L365), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
|
||||
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161) |
|
||||
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:291`](../ui/dr-ui/src/library.rs#L291) |
|
||||
| FR-DEV-3 | [`core/dr-pipeline/src/framing.rs:185`](../core/dr-pipeline/src/framing.rs#L185) |
|
||||
| FR-DEV-3a | [`core/dr-pipeline/build.rs:1734`](../core/dr-pipeline/build.rs#L1734), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:256`](../core/dr-pipeline/src/framing.rs#L256), [`core/dr-pipeline/src/graph.rs:133`](../core/dr-pipeline/src/graph.rs#L133), [`core/dr-pipeline/src/graph.rs:17`](../core/dr-pipeline/src/graph.rs#L17), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/operation.rs:106`](../core/dr-pipeline/src/operation.rs#L106) |
|
||||
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:256`](../core/dr-pipeline/src/framing.rs#L256), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/operation.rs:106`](../core/dr-pipeline/src/operation.rs#L106) |
|
||||
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1734`](../core/dr-pipeline/build.rs#L1734), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:133`](../core/dr-pipeline/src/graph.rs#L133), [`ui/dr-ui/src/develop.rs:1459`](../ui/dr-ui/src/develop.rs#L1459) |
|
||||
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1734`](../core/dr-pipeline/build.rs#L1734), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:133`](../core/dr-pipeline/src/graph.rs#L133), [`ui/dr-ui/src/develop.rs:1654`](../ui/dr-ui/src/develop.rs#L1654) |
|
||||
| FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:185`](../core/dr-pipeline/src/framing.rs#L185) |
|
||||
| FR-DEV-3e | [`core/dr-decode/src/lib.rs:492`](../core/dr-decode/src/lib.rs#L492), [`core/dr-decode/src/lib.rs:612`](../core/dr-decode/src/lib.rs#L612) |
|
||||
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:199`](../core/dr-pipeline/src/framing.rs#L199), [`core/dr-types/src/lib.rs:332`](../core/dr-types/src/lib.rs#L332) |
|
||||
| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:135`](../core/dr-gpu/src/lib.rs#L135) |
|
||||
| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:26`](../ui/dr-ui/src/develop.rs#L26), [`ui/dr-ui/src/develop.rs:924`](../ui/dr-ui/src/develop.rs#L924), [`ui/dr-ui/src/develop.rs:934`](../ui/dr-ui/src/develop.rs#L934), [`ui/dr-ui/src/lib.rs:970`](../ui/dr-ui/src/lib.rs#L970) |
|
||||
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:885`](../ui/dr-ui/src/develop.rs#L885), [`ui/dr-ui/src/develop.rs:895`](../ui/dr-ui/src/develop.rs#L895), [`ui/dr-ui/src/lib.rs:942`](../ui/dr-ui/src/lib.rs#L942), [`ui/dr-ui/src/library.rs:1327`](../ui/dr-ui/src/library.rs#L1327), [`ui/dr-ui/src/library.rs:291`](../ui/dr-ui/src/library.rs#L291), [`ui/dr-ui/src/library.rs:319`](../ui/dr-ui/src/library.rs#L319), [`ui/dr-ui/src/library_ui.rs:1540`](../ui/dr-ui/src/library_ui.rs#L1540), [`ui/dr-ui/src/library_ui.rs:333`](../ui/dr-ui/src/library_ui.rs#L333), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:503`](../ui/dr-ui/src/settings_ui.rs#L503), [`ui/dr-ui/ui/adjust.slint:569`](../ui/dr-ui/ui/adjust.slint#L569), [`ui/dr-ui/ui/library.slint:455`](../ui/dr-ui/ui/library.slint#L455), [`ui/dr-ui/ui/library.slint:474`](../ui/dr-ui/ui/library.slint#L474), [`ui/dr-ui/ui/library.slint:748`](../ui/dr-ui/ui/library.slint#L748), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) |
|
||||
| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:48`](../ui/dr-ui/src/lib.rs#L48) |
|
||||
| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:210`](../core/dr-gpu/src/lib.rs#L210) |
|
||||
| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:26`](../ui/dr-ui/src/develop.rs#L26), [`ui/dr-ui/src/develop.rs:978`](../ui/dr-ui/src/develop.rs#L978), [`ui/dr-ui/src/develop.rs:988`](../ui/dr-ui/src/develop.rs#L988), [`ui/dr-ui/src/lib.rs:1027`](../ui/dr-ui/src/lib.rs#L1027) |
|
||||
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:939`](../ui/dr-ui/src/develop.rs#L939), [`ui/dr-ui/src/develop.rs:949`](../ui/dr-ui/src/develop.rs#L949), [`ui/dr-ui/src/lib.rs:999`](../ui/dr-ui/src/lib.rs#L999), [`ui/dr-ui/src/library.rs:1327`](../ui/dr-ui/src/library.rs#L1327), [`ui/dr-ui/src/library.rs:291`](../ui/dr-ui/src/library.rs#L291), [`ui/dr-ui/src/library.rs:319`](../ui/dr-ui/src/library.rs#L319), [`ui/dr-ui/src/library_ui.rs:1624`](../ui/dr-ui/src/library_ui.rs#L1624), [`ui/dr-ui/src/library_ui.rs:333`](../ui/dr-ui/src/library_ui.rs#L333), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:503`](../ui/dr-ui/src/settings_ui.rs#L503), [`ui/dr-ui/ui/adjust.slint:569`](../ui/dr-ui/ui/adjust.slint#L569), [`ui/dr-ui/ui/library.slint:455`](../ui/dr-ui/ui/library.slint#L455), [`ui/dr-ui/ui/library.slint:480`](../ui/dr-ui/ui/library.slint#L480), [`ui/dr-ui/ui/library.slint:785`](../ui/dr-ui/ui/library.slint#L785), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) |
|
||||
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1147`](../core/dr-gpu/src/adjust.rs#L1147), [`core/dr-gpu/src/adjust.rs:1180`](../core/dr-gpu/src/adjust.rs#L1180), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/lib.rs:47`](../core/dr-gpu/src/lib.rs#L47), [`core/dr-gpu/src/lib.rs:87`](../core/dr-gpu/src/lib.rs#L87), [`ui/dr-ui/src/develop.rs:1126`](../ui/dr-ui/src/develop.rs#L1126), [`ui/dr-ui/src/develop.rs:1200`](../ui/dr-ui/src/develop.rs#L1200), [`ui/dr-ui/src/develop.rs:1234`](../ui/dr-ui/src/develop.rs#L1234), [`ui/dr-ui/src/develop.rs:532`](../ui/dr-ui/src/develop.rs#L532), [`ui/dr-ui/src/lib.rs:54`](../ui/dr-ui/src/lib.rs#L54), [`ui/dr-ui/src/lib.rs:585`](../ui/dr-ui/src/lib.rs#L585) |
|
||||
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:168`](../core/dr-pipeline/src/operation.rs#L168), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) |
|
||||
| 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:2157`](../ui/dr-ui/src/develop.rs#L2157), [`ui/dr-ui/src/develop.rs:2189`](../ui/dr-ui/src/develop.rs#L2189), [`ui/dr-ui/src/develop.rs:37`](../ui/dr-ui/src/develop.rs#L37), [`ui/dr-ui/src/develop.rs:576`](../ui/dr-ui/src/develop.rs#L576), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1079`](../ui/dr-ui/src/lib.rs#L1079), [`ui/dr-ui/src/lib.rs:279`](../ui/dr-ui/src/lib.rs#L279), [`ui/dr-ui/ui/app.slint:227`](../ui/dr-ui/ui/app.slint#L227), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
|
||||
| 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:137`](../core/dr-export/src/lib.rs#L137), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:50`](../core/dr-export/src/lib.rs#L50), [`core/dr-gpu/src/adjust.rs:1265`](../core/dr-gpu/src/adjust.rs#L1265), [`core/dr-pipeline/src/graph.rs:298`](../core/dr-pipeline/src/graph.rs#L298), [`core/dr-pipeline/src/operation.rs:168`](../core/dr-pipeline/src/operation.rs#L168), [`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:454`](../core/dr-types/src/settings.rs#L454), [`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:137`](../core/dr-export/src/lib.rs#L137), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:50`](../core/dr-export/src/lib.rs#L50), [`core/dr-gpu/src/adjust.rs:1328`](../core/dr-gpu/src/adjust.rs#L1328), [`core/dr-pipeline/src/graph.rs:298`](../core/dr-pipeline/src/graph.rs#L298), [`core/dr-pipeline/src/operation.rs:168`](../core/dr-pipeline/src/operation.rs#L168), [`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:454`](../core/dr-types/src/settings.rs#L454), [`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:1`](../core/dr-export/src/lib.rs#L1), [`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-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-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:297`](../ui/dr-ui/src/lib.rs#L297), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:558`](../ui/dr-ui/src/settings_ui.rs#L558) |
|
||||
| FR-EXP-7 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/library_ui.rs:2132`](../ui/dr-ui/src/library_ui.rs#L2132) |
|
||||
| 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:317`](../ui/dr-ui/src/lib.rs#L317), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:558`](../ui/dr-ui/src/settings_ui.rs#L558) |
|
||||
| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:879`](../ui/dr-ui/src/export.rs#L879), [`ui/dr-ui/src/lib.rs:1587`](../ui/dr-ui/src/lib.rs#L1587), [`ui/dr-ui/src/lib.rs:170`](../ui/dr-ui/src/lib.rs#L170), [`ui/dr-ui/src/lib.rs:317`](../ui/dr-ui/src/lib.rs#L317), [`ui/dr-ui/src/lib.rs:352`](../ui/dr-ui/src/lib.rs#L352), [`ui/dr-ui/src/library_ui.rs:2216`](../ui/dr-ui/src/library_ui.rs#L2216), [`ui/dr-ui/src/library_ui.rs:3843`](../ui/dr-ui/src/library_ui.rs#L3843), [`ui/dr-ui/src/library_ui.rs:3855`](../ui/dr-ui/src/library_ui.rs#L3855), [`ui/dr-ui/src/library_ui.rs:3867`](../ui/dr-ui/src/library_ui.rs#L3867), [`ui/dr-ui/src/library_ui.rs:424`](../ui/dr-ui/src/library_ui.rs#L424), [`ui/dr-ui/src/library_ui.rs:495`](../ui/dr-ui/src/library_ui.rs#L495), [`ui/dr-ui/src/library_ui.rs:552`](../ui/dr-ui/src/library_ui.rs#L552), [`ui/dr-ui/ui/app.slint:677`](../ui/dr-ui/ui/app.slint#L677), [`ui/dr-ui/ui/app.slint:973`](../ui/dr-ui/ui/app.slint#L973), [`ui/dr-ui/ui/library.slint:459`](../ui/dr-ui/ui/library.slint#L459), [`ui/dr-ui/ui/library.slint:495`](../ui/dr-ui/ui/library.slint#L495), [`ui/dr-ui/ui/library.slint:793`](../ui/dr-ui/ui/library.slint#L793) |
|
||||
| FR-EXP-8 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`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:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-export/src/lib.rs:125`](../core/dr-export/src/lib.rs#L125), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:332`](../core/dr-gpu/src/adjust.rs#L332), [`ui/dr-ui/src/develop.rs:604`](../ui/dr-ui/src/develop.rs#L604), [`ui/dr-ui/src/lib.rs:297`](../ui/dr-ui/src/lib.rs#L297) |
|
||||
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-export/src/lib.rs:125`](../core/dr-export/src/lib.rs#L125), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:362`](../core/dr-gpu/src/adjust.rs#L362), [`ui/dr-ui/src/develop.rs:654`](../ui/dr-ui/src/develop.rs#L654), [`ui/dr-ui/src/lib.rs:317`](../ui/dr-ui/src/lib.rs#L317) |
|
||||
| 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/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) |
|
||||
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/library.rs:1364`](../ui/dr-ui/src/library.rs#L1364), [`ui/dr-ui/src/library.rs:349`](../ui/dr-ui/src/library.rs#L349), [`ui/dr-ui/src/library.rs:619`](../ui/dr-ui/src/library.rs#L619), [`ui/dr-ui/src/library_ui.rs:1088`](../ui/dr-ui/src/library_ui.rs#L1088), [`ui/dr-ui/src/library_ui.rs:2132`](../ui/dr-ui/src/library_ui.rs#L2132), [`ui/dr-ui/src/library_ui.rs:365`](../ui/dr-ui/src/library_ui.rs#L365), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/library.rs:1364`](../ui/dr-ui/src/library.rs#L1364), [`ui/dr-ui/src/library.rs:349`](../ui/dr-ui/src/library.rs#L349), [`ui/dr-ui/src/library.rs:619`](../ui/dr-ui/src/library.rs#L619), [`ui/dr-ui/src/library_ui.rs:1172`](../ui/dr-ui/src/library_ui.rs#L1172), [`ui/dr-ui/src/library_ui.rs:2216`](../ui/dr-ui/src/library_ui.rs#L2216), [`ui/dr-ui/src/library_ui.rs:365`](../ui/dr-ui/src/library_ui.rs#L365), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:811`](../core/dr-sync-nextcloud/src/lib.rs#L811), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) |
|
||||
| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) |
|
||||
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`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) |
|
||||
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`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:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
|
||||
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51) |
|
||||
| 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/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`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/lib.rs:1281`](../ui/dr-ui/src/lib.rs#L1281), [`ui/dr-ui/src/lib.rs:1847`](../ui/dr-ui/src/lib.rs#L1847), [`ui/dr-ui/src/library.rs:1106`](../ui/dr-ui/src/library.rs#L1106), [`ui/dr-ui/src/library.rs:1129`](../ui/dr-ui/src/library.rs#L1129), [`ui/dr-ui/src/library.rs:1287`](../ui/dr-ui/src/library.rs#L1287), [`ui/dr-ui/src/library_ui.rs:1013`](../ui/dr-ui/src/library_ui.rs#L1013), [`ui/dr-ui/src/library_ui.rs:1245`](../ui/dr-ui/src/library_ui.rs#L1245), [`ui/dr-ui/src/library_ui.rs:187`](../ui/dr-ui/src/library_ui.rs#L187), [`ui/dr-ui/src/library_ui.rs:198`](../ui/dr-ui/src/library_ui.rs#L198), [`ui/dr-ui/src/library_ui.rs:207`](../ui/dr-ui/src/library_ui.rs#L207), [`ui/dr-ui/src/library_ui.rs:266`](../ui/dr-ui/src/library_ui.rs#L266), [`ui/dr-ui/src/library_ui.rs:276`](../ui/dr-ui/src/library_ui.rs#L276), [`ui/dr-ui/src/library_ui.rs:318`](../ui/dr-ui/src/library_ui.rs#L318), [`ui/dr-ui/src/library_ui.rs:3305`](../ui/dr-ui/src/library_ui.rs#L3305), [`ui/dr-ui/src/library_ui.rs:381`](../ui/dr-ui/src/library_ui.rs#L381), [`ui/dr-ui/src/library_ui.rs:412`](../ui/dr-ui/src/library_ui.rs#L412), [`ui/dr-ui/src/library_ui.rs:793`](../ui/dr-ui/src/library_ui.rs#L793), [`ui/dr-ui/src/library_ui.rs:894`](../ui/dr-ui/src/library_ui.rs#L894), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/library.slint:504`](../ui/dr-ui/ui/library.slint#L504) |
|
||||
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`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/lib.rs:1363`](../ui/dr-ui/src/lib.rs#L1363), [`ui/dr-ui/src/lib.rs:2021`](../ui/dr-ui/src/lib.rs#L2021), [`ui/dr-ui/src/library.rs:1106`](../ui/dr-ui/src/library.rs#L1106), [`ui/dr-ui/src/library.rs:1129`](../ui/dr-ui/src/library.rs#L1129), [`ui/dr-ui/src/library.rs:1287`](../ui/dr-ui/src/library.rs#L1287), [`ui/dr-ui/src/library_ui.rs:1097`](../ui/dr-ui/src/library_ui.rs#L1097), [`ui/dr-ui/src/library_ui.rs:1329`](../ui/dr-ui/src/library_ui.rs#L1329), [`ui/dr-ui/src/library_ui.rs:187`](../ui/dr-ui/src/library_ui.rs#L187), [`ui/dr-ui/src/library_ui.rs:198`](../ui/dr-ui/src/library_ui.rs#L198), [`ui/dr-ui/src/library_ui.rs:207`](../ui/dr-ui/src/library_ui.rs#L207), [`ui/dr-ui/src/library_ui.rs:266`](../ui/dr-ui/src/library_ui.rs#L266), [`ui/dr-ui/src/library_ui.rs:276`](../ui/dr-ui/src/library_ui.rs#L276), [`ui/dr-ui/src/library_ui.rs:318`](../ui/dr-ui/src/library_ui.rs#L318), [`ui/dr-ui/src/library_ui.rs:3394`](../ui/dr-ui/src/library_ui.rs#L3394), [`ui/dr-ui/src/library_ui.rs:381`](../ui/dr-ui/src/library_ui.rs#L381), [`ui/dr-ui/src/library_ui.rs:412`](../ui/dr-ui/src/library_ui.rs#L412), [`ui/dr-ui/src/library_ui.rs:424`](../ui/dr-ui/src/library_ui.rs#L424), [`ui/dr-ui/src/library_ui.rs:877`](../ui/dr-ui/src/library_ui.rs#L877), [`ui/dr-ui/src/library_ui.rs:978`](../ui/dr-ui/src/library_ui.rs#L978), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/library.slint:541`](../ui/dr-ui/ui/library.slint#L541) |
|
||||
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:115`](../core/dr-types/src/lib.rs#L115), [`core/dr-types/src/lib.rs:197`](../core/dr-types/src/lib.rs#L197), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
|
||||
| FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) |
|
||||
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108), [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/lib.rs:1250`](../ui/dr-ui/src/lib.rs#L1250), [`ui/dr-ui/src/library.rs:291`](../ui/dr-ui/src/library.rs#L291), [`ui/dr-ui/src/library_ui.rs:333`](../ui/dr-ui/src/library_ui.rs#L333) |
|
||||
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108), [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/lib.rs:1332`](../ui/dr-ui/src/lib.rs#L1332), [`ui/dr-ui/src/library.rs:291`](../ui/dr-ui/src/library.rs#L291), [`ui/dr-ui/src/library_ui.rs:333`](../ui/dr-ui/src/library_ui.rs#L333) |
|
||||
| FR-NC-9 | [`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-pipeline/src/sidecar.rs:212`](../core/dr-pipeline/src/sidecar.rs#L212), [`ui/dr-ui/src/library.rs:619`](../ui/dr-ui/src/library.rs#L619), [`ui/dr-ui/src/library.rs:746`](../ui/dr-ui/src/library.rs#L746) |
|
||||
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:49`](../core/dr-types/src/lib.rs#L49) |
|
||||
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||
| FR-PLAT-LIN-1 | [`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) |
|
||||
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:198`](../core/dr-decode/src/lib.rs#L198), [`core/dr-types/src/lib.rs:125`](../core/dr-types/src/lib.rs#L125), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196) |
|
||||
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-decode/src/lib.rs:94`](../core/dr-decode/src/lib.rs#L94) |
|
||||
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
||||
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:170`](../ui/dr-ui/src/lib.rs#L170) |
|
||||
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:122`](../core/dr-decode/src/lib.rs#L122), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:573`](../core/dr-gpu/src/demosaic.rs#L573), [`core/dr-gpu/src/demosaic.rs:652`](../core/dr-gpu/src/demosaic.rs#L652), [`core/dr-gpu/src/demosaic.rs:776`](../core/dr-gpu/src/demosaic.rs#L776) |
|
||||
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:1929`](../ui/dr-ui/src/lib.rs#L1929), [`ui/dr-ui/src/lib.rs:56`](../ui/dr-ui/src/lib.rs#L56), [`ui/dr-ui/ui/library.slint:546`](../ui/dr-ui/ui/library.slint#L546) |
|
||||
| FR-UI-2 | [`ui/dr-ui/src/lib.rs:56`](../ui/dr-ui/src/lib.rs#L56) |
|
||||
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2103`](../ui/dr-ui/src/lib.rs#L2103), [`ui/dr-ui/src/lib.rs:62`](../ui/dr-ui/src/lib.rs#L62), [`ui/dr-ui/ui/library.slint:583`](../ui/dr-ui/ui/library.slint#L583) |
|
||||
| FR-UI-2 | [`ui/dr-ui/src/lib.rs:62`](../ui/dr-ui/src/lib.rs#L62) |
|
||||
| FR-UI-3 | [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||
| FR-UI-4 | [`ui/dr-ui/ui/app.slint:1122`](../ui/dr-ui/ui/app.slint#L1122) |
|
||||
| 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:1963`](../ui/dr-ui/src/lib.rs#L1963), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||
| FR-UI-4 | [`ui/dr-ui/ui/app.slint:1147`](../ui/dr-ui/ui/app.slint#L1147) |
|
||||
| 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:2137`](../ui/dr-ui/src/lib.rs#L2137), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||
| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:256`](../core/dr-pipeline/src/framing.rs#L256) |
|
||||
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1) |
|
||||
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1482`](../ui/dr-ui/src/export.rs#L1482), [`ui/dr-ui/src/export.rs:1508`](../ui/dr-ui/src/export.rs#L1508), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1621`](../ui/dr-ui/src/lib.rs#L1621), [`ui/dr-ui/ui/app.slint:688`](../ui/dr-ui/ui/app.slint#L688), [`ui/dr-ui/ui/library.slint:495`](../ui/dr-ui/ui/library.slint#L495) |
|
||||
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) |
|
||||
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
|
||||
| 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/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
||||
| NFR-P13 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
|
||||
| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`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-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:879`](../ui/dr-ui/src/export.rs#L879), [`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-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:265`](../core/dr-types/src/lib.rs#L265), [`core/dr-types/src/lib.rs:298`](../core/dr-types/src/lib.rs#L298) |
|
||||
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1) |
|
||||
| NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) |
|
||||
| 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/schema.rs:1`](../core/dr-catalog/src/schema.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-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:48`](../ui/dr-ui/src/lib.rs#L48) |
|
||||
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:54`](../ui/dr-ui/src/lib.rs#L54) |
|
||||
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`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:376`](../core/dr-thumbs/src/lib.rs#L376) |
|
||||
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
||||
| R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) |
|
||||
| R4 | [`core/dr-gpu/src/lib.rs:135`](../core/dr-gpu/src/lib.rs#L135) |
|
||||
| R4 | [`core/dr-gpu/src/lib.rs:210`](../core/dr-gpu/src/lib.rs#L210) |
|
||||
|
||||
## Not yet tagged
|
||||
|
||||
71 of 151 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
|
||||
69 of 151 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
|
||||
|
||||
<details><summary>Show untagged requirements</summary>
|
||||
|
||||
@@ -142,7 +144,6 @@ _None._
|
||||
- FR-DSP-3
|
||||
- FR-DSP-4
|
||||
- FR-DSP-5
|
||||
- FR-DSP-7
|
||||
- FR-DSP-8
|
||||
- FR-NC-11
|
||||
- FR-NC-6b
|
||||
@@ -158,7 +159,6 @@ _None._
|
||||
- NFR-A11Y-2
|
||||
- NFR-A11Y-3
|
||||
- NFR-ARCH-1
|
||||
- NFR-ARCH-3
|
||||
- NFR-COMPAT-1
|
||||
- NFR-COMPAT-2
|
||||
- NFR-OPS-2
|
||||
|
||||
@@ -80,6 +80,9 @@ pub enum Kind {
|
||||
Sync,
|
||||
/// Moving files to the trash, restoring them, or emptying it.
|
||||
Trash,
|
||||
/// TRACES: FR-EXP-7
|
||||
/// Rendering and writing finished files.
|
||||
Export,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
@@ -94,7 +97,14 @@ impl Kind {
|
||||
| Kind::Download
|
||||
| Kind::Upload
|
||||
| Kind::Sync
|
||||
| Kind::Trash => true,
|
||||
| Kind::Trash
|
||||
// A batch export fetches every original it does not already have
|
||||
// cached, which on a selection of three hundred RAW files is the
|
||||
// largest transfer this application ever starts. It moves nothing
|
||||
// at all when the whole selection is already on disk, but the coarse
|
||||
// answer has to be the cautious one: the question this flag answers
|
||||
// is asked by somebody on a metered connection.
|
||||
| Kind::Export => true,
|
||||
// Reads headers over the network today, but it is bounded by the
|
||||
// catalog rather than by anything the user asked to move, and it
|
||||
// stores nothing. Calling it a transfer would put an hours-long
|
||||
|
||||
+949
-2
@@ -27,13 +27,45 @@
|
||||
//! promise and never do. An export waiting to upload is a promise — the user
|
||||
//! was told the export succeeded — and sweeping it away to reclaim disk would
|
||||
//! destroy work that no longer exists anywhere else.
|
||||
//!
|
||||
//! # The batch (FR-EXP-7)
|
||||
//!
|
||||
//! The second half of this file is a worker that exports a whole selection.
|
||||
//! It runs on a thread of its own and reaches the interface through the same
|
||||
//! two mechanisms everything else here does: an `mpsc` channel drained by a
|
||||
//! Slint timer, and a row in [`crate::activity`].
|
||||
//!
|
||||
//! **Why the worker opens its own sessions.** A [`crate::DevelopSession`] owns a GPU
|
||||
//! `AdjustPass`, and the one the interface is holding is the one the canvas
|
||||
//! renders from — handing it to a worker would mean the develop view could not
|
||||
//! draw while a batch ran, which is the freeze this exists to remove. A
|
||||
//! `GpuContext` is an `Arc` pair over a device and queue and is cheap to
|
||||
//! clone, so the worker takes a clone and opens each photograph for itself.
|
||||
//! The cost is a `Demosaicer` and an `AdjustPass` per image rather than one
|
||||
//! for the run; against a full-resolution decode, render and encode it is
|
||||
//! small, and it keeps this file out of the pipeline that `develop` owns.
|
||||
//!
|
||||
//! **The open image is the exception.** Its edit lives in the interface's
|
||||
//! session and may not have reached a sidecar yet, so a worker that re-opened
|
||||
//! the file for itself would export the *saved* version rather than the one on
|
||||
//! screen. That one frame is therefore rendered by the caller and handed over
|
||||
//! as [`Source::Rendered`]; everything after the render still moves off the UI
|
||||
//! thread.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use dr_export::Encoded;
|
||||
use dr_export::{Encoded, NameContext};
|
||||
use dr_sync::{RemoteBackend, RemotePath};
|
||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||
use dr_types::ExportTarget;
|
||||
use dr_types::{ExportSettings, ExportTarget};
|
||||
|
||||
use crate::AppWindow;
|
||||
|
||||
/// Where exports wait for a server that is not there yet.
|
||||
///
|
||||
@@ -371,6 +403,619 @@ pub fn spawn_upload(
|
||||
rx
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch export (FR-EXP-7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// TRACES: NFR-ARCH-3
|
||||
/// The stop flag a running batch reads.
|
||||
///
|
||||
/// One bit rather than a channel. Cancellation is read thousands of times more
|
||||
/// often than it is written, and a message would only be seen if the worker
|
||||
/// happened to be listening at the moment the button was pressed rather than at
|
||||
/// the next point it is safe to stop.
|
||||
///
|
||||
/// `Relaxed` throughout: the flag guards no other data, so there is nothing for
|
||||
/// an acquire/release pair to publish. The only ordering that matters is that
|
||||
/// the write eventually becomes visible, which every ordering guarantees.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Cancel(Arc<AtomicBool>);
|
||||
|
||||
impl Cancel {
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the worker blocks on another worker before rereading the flag.
|
||||
///
|
||||
/// TRACES: NFR-ARCH-3
|
||||
/// This is the cancellation bound for everything the batch spends its time
|
||||
/// *waiting* on — a download of tens of megabytes would otherwise hold a
|
||||
/// cancelled batch open until the transfer finished. It is not the bound for
|
||||
/// the frame being rendered and encoded when the button is pressed: that has no
|
||||
/// interior stopping point, so the true worst case is one image, and on a 24 MP
|
||||
/// frame that is well past NFR-ARCH-3's 100 ms target. Closing that gap needs
|
||||
/// the render itself to become interruptible (NFR-ARCH-2's scheduler), not a
|
||||
/// finer poll here.
|
||||
const CANCEL_POLL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// How often the drain looks at what the worker has said.
|
||||
const DRAIN_INTERVAL: Duration = Duration::from_millis(120);
|
||||
|
||||
/// Where one image's pixels come from.
|
||||
pub enum Source {
|
||||
/// A photograph in the library, fetched and rendered by the worker.
|
||||
///
|
||||
/// Carries its own cache context because that is assembled from the catalog
|
||||
/// and the session, and a worker thread can reach neither.
|
||||
Library {
|
||||
path: String,
|
||||
cache: Option<crate::library::CacheContext>,
|
||||
},
|
||||
/// A frame the caller has already rendered — the image open in develop.
|
||||
///
|
||||
/// See the module docs for why this one cannot be left to the worker.
|
||||
Rendered {
|
||||
stem: String,
|
||||
frame: dr_export::Frame,
|
||||
},
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// What to call this image in a progress row.
|
||||
fn describe(&self) -> String {
|
||||
match self {
|
||||
Source::Library { path, .. } => path.rsplit('/').next().unwrap_or(path).to_string(),
|
||||
Source::Rendered { stem, .. } => stem.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a batch needs, assembled on the UI thread.
|
||||
///
|
||||
/// Assembled there and not in the worker because most of it is only reachable
|
||||
/// from a `RefCell` the interface owns: the session, the settings record, the
|
||||
/// catalog. Passing the finished answers means the worker borrows nothing.
|
||||
pub struct BatchRequest {
|
||||
pub sources: Vec<Source>,
|
||||
/// Credentials for the account the library is open on. `None` where no
|
||||
/// library is open, which is fine for a [`Source::Rendered`] and fatal for
|
||||
/// anything that has to be fetched.
|
||||
pub creds: Option<(AppCredentials, String)>,
|
||||
pub settings: ExportSettings,
|
||||
pub outbox: PathBuf,
|
||||
pub sidecar_cache: PathBuf,
|
||||
pub offline: bool,
|
||||
/// `None` on a build with no adapter, where a library image cannot be
|
||||
/// rendered at all — reported per image rather than refused up front, so
|
||||
/// the reason lands in the same place every other failure does.
|
||||
pub gpu: Option<dr_gpu::GpuContext>,
|
||||
}
|
||||
|
||||
/// TRACES: NFR-ARCH-4
|
||||
/// Why one image of a batch produced no file.
|
||||
///
|
||||
/// One variant per stage. "Export failed" repeated forty times is not a report
|
||||
/// anybody can act on: a destination folder that cannot be written is one fix,
|
||||
/// a body rawler cannot decode is another, and a server that went away is a
|
||||
/// third — and only the first is worth stopping the batch to correct.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ItemError {
|
||||
#[error("could not be fetched: {0}")]
|
||||
Fetch(String),
|
||||
|
||||
#[error("could not be opened for editing: {0}")]
|
||||
Open(String),
|
||||
|
||||
#[error("could not be rendered: {0}")]
|
||||
Render(String),
|
||||
|
||||
/// The name was taken and the collision policy is Skip. Not really a
|
||||
/// failure — the user asked for exactly this — but it is still an image
|
||||
/// that produced no file, and a batch reporting "40 exported" when it wrote
|
||||
/// 31 would be lying.
|
||||
#[error("a file of that name is already there, and the collision setting is Skip")]
|
||||
NameTaken,
|
||||
|
||||
#[error(transparent)]
|
||||
Encode(#[from] dr_export::ExportError),
|
||||
|
||||
#[error("could not be written: {0}")]
|
||||
Place(String),
|
||||
}
|
||||
|
||||
/// What the worker says as it goes.
|
||||
#[derive(Debug)]
|
||||
pub enum BatchMessage {
|
||||
/// Work has begun on one image; `done` counts the ones behind it.
|
||||
Started {
|
||||
done: usize,
|
||||
total: usize,
|
||||
name: String,
|
||||
},
|
||||
/// One image is finished, for better or worse.
|
||||
Item {
|
||||
name: String,
|
||||
outcome: Result<Placed, ItemError>,
|
||||
},
|
||||
Finished {
|
||||
exported: usize,
|
||||
failed: usize,
|
||||
cancelled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Export a selection on a thread of its own.
|
||||
pub fn spawn_batch(request: BatchRequest, cancel: Cancel) -> Receiver<BatchMessage> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || run(request, &cancel, &tx));
|
||||
rx
|
||||
}
|
||||
|
||||
/// The batch itself, one image at a time.
|
||||
///
|
||||
/// Sequential rather than parallel, which FR-EXP-7's "uses all available cores"
|
||||
/// does not yet get. One reason and one excuse: the reason is that a
|
||||
/// full-resolution frame is tens of megabytes and four in flight is a
|
||||
/// straightforward way to exhaust a tablet; the excuse is that the GPU is
|
||||
/// shared with the interface, and the render is where the time goes.
|
||||
fn run(mut request: BatchRequest, cancel: &Cancel, tx: &Sender<BatchMessage>) {
|
||||
let sources = std::mem::take(&mut request.sources);
|
||||
let total = sources.len();
|
||||
|
||||
// Names this run has already written. See [`resolve_batch_name`] for why
|
||||
// the destination alone is not enough to keep two exports apart.
|
||||
let mut issued: HashSet<String> = HashSet::new();
|
||||
let (mut exported, mut failed) = (0usize, 0usize);
|
||||
|
||||
for (i, source) in sources.into_iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
|
||||
let name = source.describe();
|
||||
let _ = tx.send(BatchMessage::Started {
|
||||
done: i,
|
||||
total,
|
||||
name: name.clone(),
|
||||
});
|
||||
|
||||
// `None` is cancellation mid-image, which is not an outcome for this
|
||||
// photograph: nothing failed, the user simply stopped asking.
|
||||
let Some(outcome) = export_one(&request, source, i as u32 + 1, &mut issued, cancel) else {
|
||||
break;
|
||||
};
|
||||
|
||||
// Counted, reported, and then on to the next one. A failure here must
|
||||
// not end the run — the whole point of exporting three hundred frames
|
||||
// unattended is that the one unreadable file is a line in a report
|
||||
// rather than an evening lost (FR-EXP-7).
|
||||
match &outcome {
|
||||
Ok(_) => exported += 1,
|
||||
Err(_) => failed += 1,
|
||||
}
|
||||
let _ = tx.send(BatchMessage::Item { name, outcome });
|
||||
}
|
||||
|
||||
let _ = tx.send(BatchMessage::Finished {
|
||||
exported,
|
||||
failed,
|
||||
cancelled: cancel.is_cancelled(),
|
||||
});
|
||||
}
|
||||
|
||||
/// One image, start to finish. `None` where the run was cancelled part way.
|
||||
fn export_one(
|
||||
request: &BatchRequest,
|
||||
source: Source,
|
||||
sequence: u32,
|
||||
issued: &mut HashSet<String>,
|
||||
cancel: &Cancel,
|
||||
) -> Option<Result<Placed, ItemError>> {
|
||||
let (stem, date, frame) = match source {
|
||||
Source::Rendered { stem, frame } => (stem, String::new(), frame),
|
||||
Source::Library { path, cache } => {
|
||||
match render_from_library(request, &path, cache, cancel)? {
|
||||
Ok(rendered) => rendered,
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some(place_frame(request, &stem, &date, sequence, &frame, issued))
|
||||
}
|
||||
|
||||
/// Fetch a photograph, apply its stored edit, and render it at full size.
|
||||
fn render_from_library(
|
||||
request: &BatchRequest,
|
||||
path: &str,
|
||||
cache: Option<crate::library::CacheContext>,
|
||||
cancel: &Cancel,
|
||||
) -> Option<Result<(String, String, dr_export::Frame), ItemError>> {
|
||||
let Some((creds, user_id)) = request.creds.clone() else {
|
||||
return Some(Err(ItemError::Fetch("no library is open".into())));
|
||||
};
|
||||
let Some(gpu) = request.gpu.as_ref() else {
|
||||
return Some(Err(ItemError::Open(
|
||||
"this build found no GPU adapter".into(),
|
||||
)));
|
||||
};
|
||||
|
||||
// Both started before either is waited on, exactly as opening an image in
|
||||
// develop does: the sidecar is three orders of magnitude smaller than the
|
||||
// RAW, so it costs nothing to have in hand by the time there is a session
|
||||
// to apply it to.
|
||||
let sidecar_rx = crate::library::spawn_sidecar_fetch(
|
||||
creds.clone(),
|
||||
user_id.clone(),
|
||||
path.to_string(),
|
||||
request.sidecar_cache.clone(),
|
||||
request.offline,
|
||||
);
|
||||
let bytes_rx = crate::library::spawn_full_fetch(creds, user_id, path.to_string(), cache);
|
||||
|
||||
let bytes = match wait_for(&bytes_rx, cancel) {
|
||||
Waited::Got(Ok(bytes)) => bytes,
|
||||
Waited::Got(Err(e)) => return Some(Err(ItemError::Fetch(e.message))),
|
||||
Waited::Cancelled => return None,
|
||||
Waited::Silent => {
|
||||
return Some(Err(ItemError::Fetch(
|
||||
"the download ended without answering".into(),
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let sidecar = match wait_for(&sidecar_rx, cancel) {
|
||||
Waited::Got(sidecar) => sidecar,
|
||||
Waited::Cancelled => return None,
|
||||
// An unedited photograph has no sidecar and a fetch that died looks
|
||||
// identical from here. Exporting at defaults is what opening it would
|
||||
// do, and refusing the image over a missing edit it may never have had
|
||||
// would fail the common case.
|
||||
Waited::Silent => None,
|
||||
};
|
||||
|
||||
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
|
||||
let orientation = meta.orientation.unwrap_or_default();
|
||||
// `{date}` is the capture date, not today's: a template naming exports by
|
||||
// when the shutter fired is the reason the token exists.
|
||||
let date = meta
|
||||
.captured_at
|
||||
.map(crate::library_ui::format_date)
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut session = match crate::open_session(gpu, &bytes, orientation) {
|
||||
Ok(session) => session,
|
||||
Err(e) => return Some(Err(ItemError::Open(e))),
|
||||
};
|
||||
|
||||
// TRACES: FR-CAT-8
|
||||
// The stored edit. FR-EXP-9 is about resolution; this is the other half of
|
||||
// exporting what the user actually has, and a batch that skipped it would
|
||||
// write out three hundred unedited frames without saying so.
|
||||
if let Some(version) = sidecar
|
||||
.as_ref()
|
||||
.and_then(dr_pipeline::Sidecar::default_version)
|
||||
{
|
||||
session.apply_version(version);
|
||||
}
|
||||
|
||||
// The last cheap place to stop. Everything past here is a full-resolution
|
||||
// render and an encode with no interior stopping point — see [`CANCEL_POLL`].
|
||||
if cancel.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let frame = match session.render_for_export(request.settings.colour_space) {
|
||||
Ok(frame) => frame,
|
||||
Err(e) => return Some(Err(ItemError::Render(e))),
|
||||
};
|
||||
|
||||
let stem = Path::new(path)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "export".into());
|
||||
|
||||
Some(Ok((stem, date, frame)))
|
||||
}
|
||||
|
||||
/// Name, encode and write one rendered frame.
|
||||
///
|
||||
/// The tail every source shares, whoever rendered it.
|
||||
fn place_frame(
|
||||
request: &BatchRequest,
|
||||
stem: &str,
|
||||
date: &str,
|
||||
sequence: u32,
|
||||
frame: &dr_export::Frame,
|
||||
issued: &mut HashSet<String>,
|
||||
) -> Result<Placed, ItemError> {
|
||||
// The size is resolved before the name because `{dimensions}` is one of the
|
||||
// tokens a template can carry.
|
||||
let (width, height) = dr_export::target_size(
|
||||
frame.width,
|
||||
frame.height,
|
||||
request.settings.sizing,
|
||||
request.settings.allow_upscaling,
|
||||
);
|
||||
|
||||
let ctx = NameContext {
|
||||
source_stem: stem,
|
||||
sequence,
|
||||
date,
|
||||
width,
|
||||
height,
|
||||
preset: "",
|
||||
};
|
||||
|
||||
let name = resolve_batch_name(&request.settings, &ctx, issued).ok_or(ItemError::NameTaken)?;
|
||||
let encoded = dr_export::export(frame, &request.settings, name)?;
|
||||
|
||||
place(
|
||||
&encoded,
|
||||
request.settings.target,
|
||||
&request.settings.destination,
|
||||
&request.outbox,
|
||||
)
|
||||
.map_err(ItemError::Place)
|
||||
}
|
||||
|
||||
/// The name this export takes, avoiding both what was in the destination and
|
||||
/// what this run has already written.
|
||||
///
|
||||
/// Those are two different collisions and they deserve two different answers.
|
||||
/// [`dr_types::CollisionPolicy`] is the user's answer to "a file of this name
|
||||
/// was already there", and Overwrite is a perfectly reasonable one. It is not
|
||||
/// an answer to "the frame I exported four seconds ago was also called this":
|
||||
/// two photographs of the same stem in different folders are ordinary in a
|
||||
/// library, and a batch that quietly handed back fewer files than images —
|
||||
/// having destroyed its own output — is not something anybody asked for. So a
|
||||
/// name this run issued is always stepped past, whatever the policy says about
|
||||
/// the folder.
|
||||
fn resolve_batch_name(
|
||||
settings: &ExportSettings,
|
||||
ctx: &NameContext<'_>,
|
||||
issued: &mut HashSet<String>,
|
||||
) -> Option<String> {
|
||||
let dir = PathBuf::from(&settings.destination);
|
||||
let taken = |name: &str| -> bool {
|
||||
match settings.target {
|
||||
dr_types::ExportTarget::Device => dir.join(name).exists(),
|
||||
// A queued export cannot see the server, and may never be able to.
|
||||
// Names are kept apart in the outbox instead — see [`stage`].
|
||||
dr_types::ExportTarget::Remote => false,
|
||||
}
|
||||
};
|
||||
|
||||
let name = dr_export::resolve_name(
|
||||
&settings.filename_template,
|
||||
ctx,
|
||||
settings.format,
|
||||
settings.collision,
|
||||
&|name| taken(name) || issued.contains(name),
|
||||
)?;
|
||||
|
||||
// Only reachable under Overwrite, which hands back the taken name by
|
||||
// design. Skip and Increment have already been through the closure above.
|
||||
let name = if issued.contains(&name) {
|
||||
step_past(&name, &|candidate| {
|
||||
taken(candidate) || issued.contains(candidate)
|
||||
})?
|
||||
} else {
|
||||
name
|
||||
};
|
||||
|
||||
issued.insert(name.clone());
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// `photo.jpg` → `photo-1.jpg`, and on until the name is free.
|
||||
fn step_past(name: &str, taken: &dyn Fn(&str) -> bool) -> Option<String> {
|
||||
let path = Path::new(name);
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "export".into());
|
||||
let ext = path
|
||||
.extension()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Bounded for the same reason `resolve_name`'s own search is: a destination
|
||||
// that reports every name as taken has to fail rather than spin.
|
||||
(1..10_000)
|
||||
.map(|n| {
|
||||
if ext.is_empty() {
|
||||
format!("{stem}-{n}")
|
||||
} else {
|
||||
format!("{stem}-{n}.{ext}")
|
||||
}
|
||||
})
|
||||
.find(|candidate| !taken(candidate))
|
||||
}
|
||||
|
||||
/// What waiting on another worker produced.
|
||||
///
|
||||
/// Three answers rather than an `Option`, because "the user cancelled" and "the
|
||||
/// worker died without a word" have the same shape and want opposite treatment:
|
||||
/// one ends the batch quietly, the other is a failure belonging to one image.
|
||||
enum Waited<T> {
|
||||
Got(T),
|
||||
Cancelled,
|
||||
Silent,
|
||||
}
|
||||
|
||||
/// Block on another worker's answer without going deaf to cancellation.
|
||||
fn wait_for<T>(rx: &Receiver<T>, cancel: &Cancel) -> Waited<T> {
|
||||
loop {
|
||||
match rx.recv_timeout(CANCEL_POLL) {
|
||||
Ok(value) => return Waited::Got(value),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if cancel.is_cancelled() {
|
||||
return Waited::Cancelled;
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => return Waited::Silent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which status line a batch writes its commentary to.
|
||||
///
|
||||
/// One drain serves both entry points rather than two near-identical timers, so
|
||||
/// it has to be told where the words go: develop has its own line beside the
|
||||
/// export button, and the grid has the header's.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Reporting {
|
||||
Develop,
|
||||
Library,
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-7 | NFR-P9
|
||||
/// Drain a batch's progress on the UI thread.
|
||||
///
|
||||
/// `slot` holds the timer, and dropping what was there before is what stops a
|
||||
/// superseded batch's drain — and, through [`crate::activity::Activity`]'s
|
||||
/// `Drop`, takes its row with it.
|
||||
pub fn drain_batch(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
activity: &Rc<crate::activity::ActivityLog>,
|
||||
slot: &Rc<std::cell::RefCell<Option<slint::Timer>>>,
|
||||
rx: Receiver<BatchMessage>,
|
||||
total: usize,
|
||||
reporting: Reporting,
|
||||
) {
|
||||
let job = activity.begin(
|
||||
crate::activity::Kind::Export,
|
||||
if total == 1 {
|
||||
"Exporting".to_string()
|
||||
} else {
|
||||
format!("Exporting {total} images")
|
||||
},
|
||||
);
|
||||
job.total(total);
|
||||
|
||||
// The first failure, kept for the summary. Only the first: a run where
|
||||
// every image failed for the same reason should say that reason once, and
|
||||
// the rest are in the log.
|
||||
let mut first_failure: Option<String> = None;
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let held = slot.clone();
|
||||
|
||||
timer.start(slint::TimerMode::Repeated, DRAIN_INTERVAL, move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
loop {
|
||||
let message = match rx.try_recv() {
|
||||
Ok(m) => m,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
// A worker that died without a word must not leave the
|
||||
// button saying "Cancel export" for the rest of the session.
|
||||
job.fail("ended unexpectedly");
|
||||
settle(&w, reporting, "Export ended unexpectedly");
|
||||
stop_timer(&held);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match message {
|
||||
BatchMessage::Started { done, total, name } => {
|
||||
job.progress(done, total);
|
||||
job.detail(name.clone());
|
||||
report(
|
||||
&w,
|
||||
reporting,
|
||||
&format!("Exporting {name} ({}/{total})", done + 1),
|
||||
);
|
||||
}
|
||||
BatchMessage::Item { name, outcome } => match outcome {
|
||||
Ok(placed) => log::info!("{name}: {}", placed.describe()),
|
||||
Err(e) => {
|
||||
// Every failure is logged, not only the first: the
|
||||
// summary is a sentence and this is the record of which
|
||||
// photographs it is about (NFR-ARCH-4).
|
||||
log::warn!("exporting {name}: {e}");
|
||||
first_failure.get_or_insert_with(|| format!("{name}: {e}"));
|
||||
}
|
||||
},
|
||||
BatchMessage::Finished {
|
||||
exported,
|
||||
failed,
|
||||
cancelled,
|
||||
} => {
|
||||
let (text, is_failure) =
|
||||
summarise(exported, failed, cancelled, first_failure.as_deref());
|
||||
if is_failure {
|
||||
job.fail(text.clone());
|
||||
} else {
|
||||
job.finish(text.clone());
|
||||
}
|
||||
settle(&w, reporting, &text);
|
||||
stop_timer(&held);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
*slot.borrow_mut() = Some(timer);
|
||||
}
|
||||
|
||||
/// How a finished batch reads, and whether it counts as a failure.
|
||||
///
|
||||
/// A cancelled run is never a failure however little it exported: the user
|
||||
/// stopped it, and a red row telling them so is the application arguing. A run
|
||||
/// with even one failure is, and stays in the list until it is cleared — that
|
||||
/// is the row somebody came looking for.
|
||||
fn summarise(
|
||||
exported: usize,
|
||||
failed: usize,
|
||||
cancelled: bool,
|
||||
first_failure: Option<&str>,
|
||||
) -> (String, bool) {
|
||||
if cancelled {
|
||||
return (format!("Cancelled after {exported}"), false);
|
||||
}
|
||||
if failed == 0 {
|
||||
return (format!("Exported {exported}"), false);
|
||||
}
|
||||
let detail = first_failure.unwrap_or("see the log");
|
||||
(
|
||||
format!("Exported {exported}, {failed} failed — {detail}"),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// Say what the batch is doing on the line the caller came from.
|
||||
fn report(window: &AppWindow, reporting: Reporting, text: &str) {
|
||||
match reporting {
|
||||
Reporting::Develop => window.set_export_status(text.into()),
|
||||
Reporting::Library => window.set_library_status(text.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Report, and put the buttons back.
|
||||
fn settle(window: &AppWindow, reporting: Reporting, text: &str) {
|
||||
report(window, reporting, text);
|
||||
match reporting {
|
||||
Reporting::Develop => window.set_export_busy(false),
|
||||
Reporting::Library => window.set_library_exporting(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_timer(slot: &Rc<std::cell::RefCell<Option<slint::Timer>>>) {
|
||||
if let Some(timer) = slot.borrow().as_ref() {
|
||||
timer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -596,4 +1241,306 @@ mod tests {
|
||||
.describe()
|
||||
.contains("Exported"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The batch (FR-EXP-7)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Settings that write PNGs into `dir`, so a test can look at what landed.
|
||||
fn to_folder(dir: &Path) -> ExportSettings {
|
||||
ExportSettings {
|
||||
format: dr_types::ExportFormat::Png,
|
||||
target: ExportTarget::Device,
|
||||
destination: dir.to_string_lossy().into_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn request(settings: ExportSettings, sources: Vec<Source>) -> BatchRequest {
|
||||
BatchRequest {
|
||||
sources,
|
||||
creds: None,
|
||||
settings,
|
||||
outbox: std::env::temp_dir().join("dr-batch-test-outbox"),
|
||||
sidecar_cache: std::env::temp_dir().join("dr-batch-test-sidecars"),
|
||||
offline: true,
|
||||
gpu: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame of flat pixels — enough for the encoder, cheap for a test.
|
||||
fn frame(width: u32, height: u32) -> dr_export::Frame {
|
||||
dr_export::Frame::new(width, height, vec![128; (width * height * 4) as usize])
|
||||
.expect("well-formed")
|
||||
}
|
||||
|
||||
fn drive(request: BatchRequest, cancel: Cancel) -> Vec<BatchMessage> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
run(request, &cancel, &tx);
|
||||
drop(tx);
|
||||
rx.into_iter().collect()
|
||||
}
|
||||
|
||||
fn finished(messages: &[BatchMessage]) -> (usize, usize, bool) {
|
||||
messages
|
||||
.iter()
|
||||
.find_map(|m| match m {
|
||||
BatchMessage::Finished {
|
||||
exported,
|
||||
failed,
|
||||
cancelled,
|
||||
} => Some((*exported, *failed, *cancelled)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a batch always says it has finished")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rendered_frame_reaches_the_destination_folder() {
|
||||
// The whole tail of the batch — name, size, sharpen, encode, write —
|
||||
// with no GPU and no network, which is what makes it testable at all.
|
||||
let dir = tmp();
|
||||
let out = dir.join("exports");
|
||||
let messages = drive(
|
||||
request(
|
||||
to_folder(&out),
|
||||
vec![Source::Rendered {
|
||||
stem: "IMG_0001".into(),
|
||||
frame: frame(16, 12),
|
||||
}],
|
||||
),
|
||||
Cancel::default(),
|
||||
);
|
||||
|
||||
assert_eq!(finished(&messages), (1, 0, false));
|
||||
assert!(out.join("IMG_0001.png").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_images_of_one_name_both_survive_the_batch() {
|
||||
// Two folders in a library holding an IMG_0001 each is ordinary, and a
|
||||
// batch that wrote one file for two photographs would destroy work
|
||||
// without saying anything. Overwrite is set deliberately: it is the
|
||||
// user's answer about the *folder*, not about this run's own output.
|
||||
let dir = tmp();
|
||||
let out = dir.join("exports");
|
||||
let mut settings = to_folder(&out);
|
||||
settings.collision = dr_types::CollisionPolicy::Overwrite;
|
||||
|
||||
let messages = drive(
|
||||
request(
|
||||
settings,
|
||||
vec![
|
||||
Source::Rendered {
|
||||
stem: "IMG_0001".into(),
|
||||
frame: frame(16, 12),
|
||||
},
|
||||
Source::Rendered {
|
||||
stem: "IMG_0001".into(),
|
||||
frame: frame(16, 12),
|
||||
},
|
||||
],
|
||||
),
|
||||
Cancel::default(),
|
||||
);
|
||||
|
||||
assert_eq!(finished(&messages), (2, 0, false));
|
||||
assert!(out.join("IMG_0001.png").exists());
|
||||
assert!(out.join("IMG_0001-1.png").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_was_already_there_still_obeys_the_collision_setting() {
|
||||
// The other half of the rule above: a file that existed *before* the
|
||||
// batch is exactly what the policy is about, and Overwrite must still
|
||||
// mean overwrite or the setting would do nothing.
|
||||
let dir = tmp();
|
||||
let out = dir.join("exports");
|
||||
std::fs::create_dir_all(&out).expect("temp dir");
|
||||
std::fs::write(out.join("IMG_0001.png"), b"older").expect("seed");
|
||||
|
||||
let mut settings = to_folder(&out);
|
||||
settings.collision = dr_types::CollisionPolicy::Overwrite;
|
||||
|
||||
let mut issued = HashSet::new();
|
||||
let name = resolve_batch_name(
|
||||
&settings,
|
||||
&NameContext {
|
||||
source_stem: "IMG_0001",
|
||||
sequence: 1,
|
||||
..Default::default()
|
||||
},
|
||||
&mut issued,
|
||||
);
|
||||
assert_eq!(name.as_deref(), Some("IMG_0001.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_skipped_name_is_reported_rather_than_silently_dropped() {
|
||||
// Skip is a legitimate answer, but the image still produced no file —
|
||||
// and a batch claiming to have exported it would be lying.
|
||||
let dir = tmp();
|
||||
let out = dir.join("exports");
|
||||
std::fs::create_dir_all(&out).expect("temp dir");
|
||||
std::fs::write(out.join("IMG_0001.png"), b"older").expect("seed");
|
||||
|
||||
let mut settings = to_folder(&out);
|
||||
settings.collision = dr_types::CollisionPolicy::Skip;
|
||||
|
||||
let messages = drive(
|
||||
request(
|
||||
settings,
|
||||
vec![Source::Rendered {
|
||||
stem: "IMG_0001".into(),
|
||||
frame: frame(8, 8),
|
||||
}],
|
||||
),
|
||||
Cancel::default(),
|
||||
);
|
||||
|
||||
assert_eq!(finished(&messages), (0, 1, false));
|
||||
assert!(matches!(
|
||||
messages.iter().find_map(|m| match m {
|
||||
BatchMessage::Item { outcome, .. } => Some(outcome),
|
||||
_ => None,
|
||||
}),
|
||||
Some(Err(ItemError::NameTaken))
|
||||
));
|
||||
assert_eq!(std::fs::read(out.join("IMG_0001.png")).unwrap(), b"older");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_failure_does_not_abandon_the_rest_of_the_batch() {
|
||||
// FR-EXP-7's central promise. The first source cannot be fetched — no
|
||||
// library is open — and the two after it must still be attempted and
|
||||
// still be counted.
|
||||
let dir = tmp();
|
||||
let out = dir.join("exports");
|
||||
let messages = drive(
|
||||
request(
|
||||
to_folder(&out),
|
||||
vec![
|
||||
Source::Library {
|
||||
path: "Photos/broken.CR2".into(),
|
||||
cache: None,
|
||||
},
|
||||
Source::Rendered {
|
||||
stem: "good-a".into(),
|
||||
frame: frame(8, 8),
|
||||
},
|
||||
Source::Rendered {
|
||||
stem: "good-b".into(),
|
||||
frame: frame(8, 8),
|
||||
},
|
||||
],
|
||||
),
|
||||
Cancel::default(),
|
||||
);
|
||||
|
||||
assert_eq!(finished(&messages), (2, 1, false));
|
||||
assert!(out.join("good-a.png").exists());
|
||||
assert!(out.join("good-b.png").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_image_gets_its_own_outcome() {
|
||||
// The report is per image, not one verdict for the run: two failures
|
||||
// for two different reasons have to arrive as two messages, or a
|
||||
// three-hundred-frame batch is unreportable (NFR-ARCH-4).
|
||||
let dir = tmp();
|
||||
let messages = drive(
|
||||
request(
|
||||
to_folder(&dir.join("exports")),
|
||||
vec![
|
||||
Source::Library {
|
||||
path: "Photos/a.CR2".into(),
|
||||
cache: None,
|
||||
},
|
||||
Source::Rendered {
|
||||
stem: "b".into(),
|
||||
frame: frame(8, 8),
|
||||
},
|
||||
],
|
||||
),
|
||||
Cancel::default(),
|
||||
);
|
||||
|
||||
let outcomes: Vec<&Result<Placed, ItemError>> = messages
|
||||
.iter()
|
||||
.filter_map(|m| match m {
|
||||
BatchMessage::Item { outcome, .. } => Some(outcome),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(outcomes.len(), 2);
|
||||
assert!(outcomes[0].is_err());
|
||||
assert!(outcomes[1].is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_batch_stops_and_writes_nothing_more() {
|
||||
// TRACES: NFR-ARCH-3
|
||||
// Cancelled before it began, which is the strongest form of the
|
||||
// property: not one file, and the run still reports itself finished
|
||||
// rather than leaving the interface waiting for a message.
|
||||
let dir = tmp();
|
||||
let out = dir.join("exports");
|
||||
let cancel = Cancel::default();
|
||||
cancel.cancel();
|
||||
|
||||
let messages = drive(
|
||||
request(
|
||||
to_folder(&out),
|
||||
vec![Source::Rendered {
|
||||
stem: "IMG_0001".into(),
|
||||
frame: frame(8, 8),
|
||||
}],
|
||||
),
|
||||
cancel,
|
||||
);
|
||||
|
||||
assert_eq!(finished(&messages), (0, 0, true));
|
||||
assert!(!out.join("IMG_0001.png").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_wait_gives_up_instead_of_blocking_for_ever() {
|
||||
// TRACES: NFR-ARCH-3
|
||||
// The bound on cancelling a batch that is waiting on a download. With
|
||||
// a plain `recv` this test would hang, which is precisely the bug.
|
||||
let (tx, rx) = std::sync::mpsc::channel::<u8>();
|
||||
let cancel = Cancel::default();
|
||||
cancel.cancel();
|
||||
|
||||
assert!(matches!(wait_for(&rx, &cancel), Waited::Cancelled));
|
||||
drop(tx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_worker_that_dies_is_not_mistaken_for_a_cancellation() {
|
||||
// The two look identical from a channel and mean opposite things: one
|
||||
// ends the batch, the other fails one image and moves on.
|
||||
let (tx, rx) = std::sync::mpsc::channel::<u8>();
|
||||
drop(tx);
|
||||
assert!(matches!(wait_for(&rx, &Cancel::default()), Waited::Silent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_run_is_not_reported_as_a_failure() {
|
||||
// The user stopped it. A red row saying so is the application arguing
|
||||
// with something it was told to do.
|
||||
let (text, failed) = summarise(5, 0, true, None);
|
||||
assert!(!failed, "{text}");
|
||||
assert!(text.contains('5'), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_with_a_failure_keeps_its_row_and_names_one() {
|
||||
// This is the row somebody comes to the activity list to find, so it
|
||||
// has to survive being trimmed — and it has to say which photograph.
|
||||
let (text, failed) = summarise(38, 2, false, Some("IMG_0007.CR2: could not be rendered"));
|
||||
assert!(failed);
|
||||
assert!(text.contains("IMG_0007.CR2"), "{text}");
|
||||
assert!(text.contains("38"), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
+207
-125
@@ -122,48 +122,12 @@ fn load(ctx: Option<&dr_gpu::GpuContext>, path: &Path) -> Result<Loaded, String>
|
||||
fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded, String> {
|
||||
let meta = dr_decode::metadata(bytes).unwrap_or_default();
|
||||
|
||||
// Route by what the bytes actually are, not by extension (M-9).
|
||||
//
|
||||
// A JPEG has no sensor data and never will, so trying the RAW decoder
|
||||
// first would be a guaranteed failure whose log line reads like a fault.
|
||||
// It goes straight to the RGB path instead, which is what makes develop
|
||||
// mode work on the JPEGs the library already indexes.
|
||||
let is_jpeg = dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg);
|
||||
|
||||
// How the file stored its pixels. A file that says nothing is taken as
|
||||
// upright — see `Orientation::from_exif`.
|
||||
let orientation = meta.orientation.unwrap_or_default();
|
||||
|
||||
if let Some(ctx) = ctx {
|
||||
let opened = if is_jpeg {
|
||||
dr_decode::decode_jpeg(bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|mut p| {
|
||||
// Fit the device before uploading. A film scan runs to
|
||||
// 13728×8928, well past the 8192 a typical GPU can hold,
|
||||
// and refusing it would drop the image back to a
|
||||
// read-only preview — the very thing this path exists to
|
||||
// avoid. 8192 is still four times a 4K long edge.
|
||||
let limit = dr_gpu::DemosaicedImage::max_dimension(ctx);
|
||||
if p.width.max(p.height) > limit {
|
||||
log::info!(
|
||||
"{}×{} exceeds the {limit} texture limit; fitting to it",
|
||||
p.width,
|
||||
p.height
|
||||
);
|
||||
p.downscale_to(limit);
|
||||
}
|
||||
DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height, orientation)
|
||||
})
|
||||
} else {
|
||||
// A failure here is expected for bodies rawler does not know, and
|
||||
// must not stop the image displaying (FR-RAW-4).
|
||||
dr_decode::decode(bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|raw| DevelopSession::open(ctx, &raw, orientation))
|
||||
};
|
||||
|
||||
match opened {
|
||||
match open_session(ctx, bytes, orientation) {
|
||||
Ok(session) => {
|
||||
let (width, height) = session.source_size();
|
||||
return Ok(Loaded {
|
||||
@@ -203,6 +167,51 @@ fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded,
|
||||
})
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-7 | FR-RAW-4
|
||||
/// Open bytes for editing, with no interface types involved.
|
||||
///
|
||||
/// Split out of [`load_bytes`] so the batch exporter can share it. That runs on
|
||||
/// a worker thread, where a `slint::Image` has no business being constructed —
|
||||
/// and a second copy of the JPEG-versus-RAW routing is a second copy that would
|
||||
/// drift, which is exactly how a batch comes to export something the viewer
|
||||
/// would have shown differently.
|
||||
pub(crate) fn open_session(
|
||||
ctx: &dr_gpu::GpuContext,
|
||||
bytes: &[u8],
|
||||
orientation: dr_types::Orientation,
|
||||
) -> Result<DevelopSession, String> {
|
||||
// Route by what the bytes actually are, not by extension (M-9). A JPEG has
|
||||
// no sensor data and never will, so trying the RAW decoder first would be a
|
||||
// guaranteed failure whose log line reads like a fault.
|
||||
if dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg) {
|
||||
return dr_decode::decode_jpeg(bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|mut p| {
|
||||
// Fit the device before uploading. A film scan runs to
|
||||
// 13728×8928, well past the 8192 a typical GPU can hold, and
|
||||
// refusing it would drop the image back to a read-only preview
|
||||
// — the very thing this path exists to avoid. 8192 is still
|
||||
// four times a 4K long edge.
|
||||
let limit = dr_gpu::DemosaicedImage::max_dimension(ctx);
|
||||
if p.width.max(p.height) > limit {
|
||||
log::info!(
|
||||
"{}×{} exceeds the {limit} texture limit; fitting to it",
|
||||
p.width,
|
||||
p.height
|
||||
);
|
||||
p.downscale_to(limit);
|
||||
}
|
||||
DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height, orientation)
|
||||
});
|
||||
}
|
||||
|
||||
// A failure here is expected for bodies rawler does not know, and must not
|
||||
// stop the image displaying (FR-RAW-4).
|
||||
dr_decode::decode(bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|raw| DevelopSession::open(ctx, &raw, orientation))
|
||||
}
|
||||
|
||||
/// Collect displayable images from file or directory arguments.
|
||||
fn collect(paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
@@ -305,93 +314,66 @@ fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>
|
||||
window.set_crop_h(crop.height);
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-6 | FR-EXP-9
|
||||
/// Render the open image at full resolution and place the result.
|
||||
/// TRACES: FR-EXP-6 | FR-EXP-9 | FR-EXP-7
|
||||
/// Render the open image at full resolution, ready to be handed to the worker.
|
||||
///
|
||||
/// Synchronous, on the UI thread, and that is a known compromise rather than
|
||||
/// an oversight. A full-resolution render plus a Lanczos reduction plus an
|
||||
/// encode is hundreds of milliseconds on a 24 MP frame, and the window is
|
||||
/// unresponsive for all of it. It is done this way because the alternative —
|
||||
/// moving a `DevelopSession` and its GPU pass onto a worker — is a larger
|
||||
/// change than the button is worth before batch export exists, and a batch is
|
||||
/// what makes the wait intolerable rather than merely noticeable. The status
|
||||
/// line says what is happening in the meantime.
|
||||
fn export_now(
|
||||
/// The render stays here, on the UI thread, and everything after it does not.
|
||||
/// That split is deliberate rather than the remains of the synchronous version
|
||||
/// this replaced: the frame belongs to the `DevelopSession` the interface owns,
|
||||
/// and the edit in it may not have reached a sidecar yet, so a worker that
|
||||
/// re-opened the photograph for itself would export the *saved* version rather
|
||||
/// than the one on screen.
|
||||
///
|
||||
/// What is left on this thread is a GPU pass and a readback. The Lanczos
|
||||
/// reduction, the encode and the write — the larger half of the wait, and all
|
||||
/// of its variance — leave with the frame.
|
||||
fn render_open_frame(
|
||||
window: &AppWindow,
|
||||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||
settings: &Rc<settings_ui::SettingsController>,
|
||||
library: &Rc<library_ui::LibraryController>,
|
||||
) -> Result<export::Placed, String> {
|
||||
space: dr_types::ColourSpace,
|
||||
) -> Result<export::Source, String> {
|
||||
let mut borrowed = session.borrow_mut();
|
||||
let Some(session) = borrowed.as_mut() else {
|
||||
return Err("nothing is open".into());
|
||||
};
|
||||
|
||||
let stored = settings.snapshot();
|
||||
let frame = session.render_for_export(stored.export.colour_space)?;
|
||||
|
||||
// The size has to be resolved before the name, because `{dimensions}` is
|
||||
// one of the tokens the template can carry.
|
||||
let (tw, th) = dr_export::target_size(
|
||||
frame.width,
|
||||
frame.height,
|
||||
stored.export.sizing,
|
||||
stored.export.allow_upscaling,
|
||||
);
|
||||
|
||||
// The colour space is chosen at render time because the conversion happens
|
||||
// in the shader, before the clip to 0..1 — see `render_for_export`.
|
||||
let frame = session.render_for_export(space)?;
|
||||
let filename = window.get_filename().to_string();
|
||||
let stem = std::path::Path::new(&filename)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "export".into());
|
||||
|
||||
let outbox = match library.session() {
|
||||
Ok(export::Source::Rendered { stem, frame })
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-7
|
||||
/// Everything a batch needs that only the UI thread can assemble.
|
||||
fn batch_request(
|
||||
sources: Vec<export::Source>,
|
||||
settings: &Rc<settings_ui::SettingsController>,
|
||||
library: &Rc<library_ui::LibraryController>,
|
||||
gpu: Option<&dr_gpu::GpuContext>,
|
||||
) -> export::BatchRequest {
|
||||
let stored = settings.snapshot();
|
||||
|
||||
export::BatchRequest {
|
||||
sources,
|
||||
creds: library.credentials(),
|
||||
settings: stored.export,
|
||||
outbox: match library.session() {
|
||||
Some((_, s)) => export::outbox_dir(&s.server, &s.user_id),
|
||||
// No account, so no outbox — a device export still works, and a
|
||||
// remote one is refused below by `place` rather than here, so the
|
||||
// message names the setting rather than the plumbing.
|
||||
// remote one is refused by `place` rather than here, so the message
|
||||
// names the setting rather than the plumbing.
|
||||
None => std::env::temp_dir().join("darkroom-outbox"),
|
||||
};
|
||||
|
||||
let ctx = dr_export::NameContext {
|
||||
source_stem: &stem,
|
||||
sequence: 1,
|
||||
date: "",
|
||||
width: tw,
|
||||
height: th,
|
||||
preset: "",
|
||||
};
|
||||
|
||||
// What counts as "taken" depends on where this is going. A device export
|
||||
// can look at the folder; a queued one is checked against the outbox,
|
||||
// since the server cannot be reached from here and may not be reachable
|
||||
// at all — see `export::stage` for why two queued exports of one name
|
||||
// both survive regardless.
|
||||
let target_dir = std::path::PathBuf::from(stored.export.active_destination());
|
||||
let taken = |name: &str| -> bool {
|
||||
match stored.export.target {
|
||||
dr_types::ExportTarget::Device => target_dir.join(name).exists(),
|
||||
dr_types::ExportTarget::Remote => false,
|
||||
},
|
||||
sidecar_cache: library.sidecar_cache_dir().unwrap_or_default(),
|
||||
offline: library.is_offline(),
|
||||
gpu: gpu.cloned(),
|
||||
}
|
||||
};
|
||||
|
||||
let name = dr_export::resolve_name(
|
||||
&stored.export.filename_template,
|
||||
&ctx,
|
||||
stored.export.format,
|
||||
stored.export.collision,
|
||||
&taken,
|
||||
)
|
||||
.ok_or("a file of that name is already there, and the collision setting is Skip")?;
|
||||
|
||||
let encoded = dr_export::export(&frame, &stored.export, name).map_err(|e| e.to_string())?;
|
||||
|
||||
export::place(
|
||||
&encoded,
|
||||
stored.export.target,
|
||||
stored.export.active_destination(),
|
||||
&outbox,
|
||||
)
|
||||
}
|
||||
|
||||
/// What the export button should say, given where an export would go.
|
||||
@@ -401,11 +383,19 @@ fn export_now(
|
||||
/// lands on this device or is queued for a server that may be unreachable.
|
||||
fn refresh_export_label(window: &AppWindow, settings: &Rc<settings_ui::SettingsController>) {
|
||||
let stored = settings.snapshot();
|
||||
let label = match stored.export.target {
|
||||
dr_types::ExportTarget::Device => "Export".to_string(),
|
||||
dr_types::ExportTarget::Remote => "Export to Nextcloud".to_string(),
|
||||
};
|
||||
window.set_export_label(label.into());
|
||||
let remote = stored.export.target == dr_types::ExportTarget::Remote;
|
||||
window.set_export_label(
|
||||
if remote {
|
||||
"Export to Nextcloud"
|
||||
} else {
|
||||
"Export"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
// The grid's button says the same thing about a selection, but it composes
|
||||
// its own label around a count — so it is given the fact rather than the
|
||||
// sentence (FR-EXP-7).
|
||||
window.set_export_to_server(remote);
|
||||
}
|
||||
|
||||
/// Push current parameter values back to the interface.
|
||||
@@ -1522,35 +1512,127 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
//
|
||||
// Generic by construction: they carry indices into the capability list,
|
||||
// so adding an operation needs no change here (FR-DEV-3c).
|
||||
// --- export ---------------------------------------------------------
|
||||
// --- export (FR-EXP-6, FR-EXP-7, FR-EXP-9) ---------------------------
|
||||
//
|
||||
// Renders its own frame at full resolution rather than encoding what is
|
||||
// on screen: the display render is deliberately viewport-sized
|
||||
// (FR-DSP-1), and exporting that would hand the user a soft, screen-sized
|
||||
// file with no indication anything had been lost (FR-EXP-9).
|
||||
// Two buttons, one worker. The develop view exports the image on screen and
|
||||
// the grid exports its selection; the only difference between them is who
|
||||
// renders the frames, so both hand a `Vec<Source>` to the same batch and
|
||||
// both report through the same activity row.
|
||||
//
|
||||
// A frame is always rendered at full resolution rather than taken from the
|
||||
// canvas: the display render is deliberately viewport-sized (FR-DSP-1), and
|
||||
// exporting that would hand the user a soft, screen-sized file with no
|
||||
// indication anything had been lost (FR-EXP-9).
|
||||
{
|
||||
// Replaced at each start, so the button always cancels the run it is
|
||||
// sitting on and a stale token cancels nothing.
|
||||
let cancel: Rc<RefCell<export::Cancel>> = Rc::new(RefCell::new(export::Cancel::default()));
|
||||
// Holds the drain. Assigning a new timer drops the previous one, which
|
||||
// is what takes a superseded batch's row out of the register.
|
||||
let drain: Rc<RefCell<Option<slint::Timer>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
let start = {
|
||||
let settings = settings.clone();
|
||||
let library = library.clone();
|
||||
let activity = activity.clone();
|
||||
let gpu = gpu.clone();
|
||||
let cancel = cancel.clone();
|
||||
let drain = drain.clone();
|
||||
Rc::new(
|
||||
move |window: &AppWindow, sources: Vec<export::Source>, to: export::Reporting| {
|
||||
let total = sources.len();
|
||||
let request = batch_request(sources, &settings, &library, gpu.as_ref());
|
||||
|
||||
let token = export::Cancel::default();
|
||||
*cancel.borrow_mut() = token.clone();
|
||||
|
||||
let rx = export::spawn_batch(request, token);
|
||||
export::drain_batch(window.as_weak(), &activity, &drain, rx, total, to);
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let settings = settings.clone();
|
||||
let library = library.clone();
|
||||
let start = start.clone();
|
||||
window.on_export_image(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// One batch at a time, and the grid's counts as one. The two
|
||||
// buttons share a worker slot, so starting a second run would
|
||||
// drop the first's drain — leaving a batch still writing files
|
||||
// with no progress row and a button that never comes back.
|
||||
if w.get_export_busy() || w.get_library_exporting() {
|
||||
w.set_export_status("An export is already running".into());
|
||||
return;
|
||||
}
|
||||
w.set_export_busy(true);
|
||||
// Pushed before the work rather than after: the render blocks the
|
||||
// UI thread, so a label set afterwards would never be drawn in
|
||||
// the "Exporting…" state at all.
|
||||
// Set before the render rather than after: the render is the
|
||||
// one part still on this thread, so a label written afterwards
|
||||
// would never be drawn in the "Rendering…" state at all.
|
||||
w.set_export_status("Rendering…".into());
|
||||
|
||||
let result = export_now(&w, &session, &settings, &library);
|
||||
match result {
|
||||
Ok(placed) => w.set_export_status(placed.describe().into()),
|
||||
Err(e) => w.set_export_status(format!("Export failed: {e}").into()),
|
||||
}
|
||||
match render_open_frame(&w, &session, settings.snapshot().export.colour_space) {
|
||||
Ok(source) => start(&w, vec![source], export::Reporting::Develop),
|
||||
Err(e) => {
|
||||
w.set_export_status(format!("Export failed: {e}").into());
|
||||
w.set_export_busy(false);
|
||||
}
|
||||
}
|
||||
refresh_export_label(&w, &settings);
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-EXP-7
|
||||
// The grid's selection. Nothing is rendered here — the worker fetches,
|
||||
// decodes and renders each photograph itself, so this returns to the
|
||||
// event loop immediately and a batch of three hundred is a progress bar
|
||||
// rather than a frozen window.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let library = library.clone();
|
||||
let collections = collections.clone();
|
||||
let start = start.clone();
|
||||
window.on_library_export_selection(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// See the develop button above for why one run excludes the
|
||||
// other. The button is a cancel by then, so this only catches
|
||||
// a batch started from develop and left running.
|
||||
if w.get_library_exporting() || w.get_export_busy() {
|
||||
w.set_library_status("An export is already running".into());
|
||||
return;
|
||||
}
|
||||
|
||||
let sources = library.export_sources(&collections.selected());
|
||||
if sources.is_empty() {
|
||||
// Said out loud rather than ignored, matching what a paste
|
||||
// or a judgement keystroke does with an empty selection.
|
||||
w.set_library_status("Select an image first".into());
|
||||
return;
|
||||
}
|
||||
|
||||
w.set_library_exporting(true);
|
||||
w.set_library_status(format!("Exporting {} images…", sources.len()).into());
|
||||
start(&w, sources, export::Reporting::Library);
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: NFR-ARCH-3
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.on_library_cancel_export(move || {
|
||||
cancel.borrow().cancel();
|
||||
if let Some(w) = weak.upgrade() {
|
||||
// The worker stops at the next point it is safe to — which
|
||||
// may be a frame away — so the button says "asked for" and
|
||||
// not "done". The drain writes the real answer.
|
||||
w.set_library_status("Cancelling the export…".into());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
|
||||
+162
-2
@@ -418,6 +418,18 @@ impl LibraryController {
|
||||
/// grid row that has scrolled away, or no library open — and the fetch
|
||||
/// then simply goes to the network.
|
||||
pub fn cache_context(&self, path: &str) -> Option<library::CacheContext> {
|
||||
self.cache_context_for(self.image_id_for_path(path)?)
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-7 | FR-NC-6a
|
||||
/// The same, for an image named by id rather than by path.
|
||||
///
|
||||
/// A batch export needs this one: its selection is by catalog id and may
|
||||
/// include photographs that have scrolled out of the loaded window, where
|
||||
/// [`Self::image_id_for_path`] has nothing to match against. Going through
|
||||
/// the path would quietly hand those images no cache at all, and a batch of
|
||||
/// three hundred would re-download every one of them.
|
||||
pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option<library::CacheContext> {
|
||||
let borrow = self.session.borrow();
|
||||
let (_, session, _) = borrow.as_ref()?;
|
||||
let catalog_path = library::catalog_path(&session.server, &session.user_id);
|
||||
@@ -427,7 +439,7 @@ impl LibraryController {
|
||||
Some(library::CacheContext {
|
||||
dir,
|
||||
catalog_path,
|
||||
image: self.image_id_for_path(path)?,
|
||||
image,
|
||||
// The user's ceiling, not the catalog's floor: read at each fetch
|
||||
// so a budget changed mid-session takes effect on the next one.
|
||||
budget: self.cache_budget.get(),
|
||||
@@ -480,6 +492,78 @@ impl LibraryController {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-7
|
||||
/// Remote paths for a set of selected images, in the order they were given.
|
||||
///
|
||||
/// Answered from the catalog rather than from `paths`, which is only the
|
||||
/// loaded window. Selection is by id precisely so that it survives a scrub
|
||||
/// (see [`crate::collections_ui`]), so a selection made before scrolling
|
||||
/// routinely names photographs no row currently holds — and an export that
|
||||
/// silently dropped those would be worse than one that refused.
|
||||
///
|
||||
/// An id the catalog has never heard of is skipped rather than reported: it
|
||||
/// can only mean the image was deleted between the selection and the click,
|
||||
/// and there is nothing to export and nothing to fix.
|
||||
///
|
||||
/// Each path comes back beside the id it belongs to, because that skipping
|
||||
/// means the ids that come out are not the ids that went in — and the caller
|
||||
/// still has to find each photograph's cache, which is keyed on the id.
|
||||
pub fn selected_image_paths(
|
||||
&self,
|
||||
images: &[dr_types::ImageId],
|
||||
) -> Vec<(dr_types::ImageId, String)> {
|
||||
if images.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let borrow = self.catalog.borrow();
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let placeholders = std::iter::repeat_n("?", images.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!("SELECT id, source_ref FROM images WHERE id IN ({placeholders})");
|
||||
let params: Vec<rusqlite::types::Value> = images
|
||||
.iter()
|
||||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||||
.collect();
|
||||
|
||||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
// `IN` returns rows in whatever order suits SQLite, and the export's
|
||||
// `{seq}` token counts through the batch — so the answer is put back
|
||||
// into the order the caller asked in rather than the one it arrived in.
|
||||
let found: std::collections::HashMap<i64, String> = rows.flatten().collect();
|
||||
images
|
||||
.iter()
|
||||
.filter_map(|id| found.get(&(id.0 as i64)).map(|path| (*id, path.clone())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-7
|
||||
/// The selection, addressed the way an export worker can fetch it.
|
||||
///
|
||||
/// Assembled here because a worker thread can reach neither the catalog nor
|
||||
/// the session, and both are needed to say where a cached original lives.
|
||||
pub fn export_sources(&self, images: &[dr_types::ImageId]) -> Vec<crate::export::Source> {
|
||||
self.selected_image_paths(images)
|
||||
.into_iter()
|
||||
.map(|(id, path)| crate::export::Source::Library {
|
||||
path,
|
||||
cache: self.cache_context_for(id),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Credentials and account for the open library, if one is open.
|
||||
///
|
||||
/// What a full-file fetch needs: the grid's paths are remote, so opening
|
||||
@@ -2629,7 +2713,12 @@ fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_date(t: i64) -> String {
|
||||
/// A capture instant as `YYYY-MM-DD`.
|
||||
///
|
||||
/// Shared with the exporter, which resolves the `{date}` token from the same
|
||||
/// reading so a filename and the timeline cannot disagree about what day a
|
||||
/// photograph was taken.
|
||||
pub fn format_date(t: i64) -> String {
|
||||
let (y, m, d, _) = civil_from_unix(t);
|
||||
format!("{y}-{m:02}-{d:02}")
|
||||
}
|
||||
@@ -3721,4 +3810,75 @@ mod tests {
|
||||
|
||||
assert_eq!(seen, vec![0, 1, 2, 3]);
|
||||
}
|
||||
|
||||
/// A controller holding a catalog of four named images.
|
||||
fn with_catalog() -> Rc<LibraryController> {
|
||||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||||
let catalog = Catalog::in_memory().expect("in-memory catalog");
|
||||
let c = catalog.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.expect("root");
|
||||
for (id, name) in [(1i64, "a.CR2"), (2, "b.CR2"), (3, "c.CR2"), (4, "d.CR2")] {
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
|
||||
rusqlite::params![id, name],
|
||||
)
|
||||
.expect("image");
|
||||
}
|
||||
*ctl.catalog.borrow_mut() = Some(catalog);
|
||||
ctl
|
||||
}
|
||||
|
||||
/// Just the names, for assertions that are about order rather than ids.
|
||||
fn named(ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) -> Vec<String> {
|
||||
ctl.selected_image_paths(images)
|
||||
.into_iter()
|
||||
.map(|(_, path)| path)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-7
|
||||
#[test]
|
||||
fn a_selection_resolves_to_paths_in_the_order_it_was_given() {
|
||||
// `IN (…)` returns rows in whatever order suits SQLite, and an export's
|
||||
// `{seq}` token counts through the batch — so a lookup that handed back
|
||||
// the database's order would number the photographs in an order the
|
||||
// user never saw.
|
||||
let ctl = with_catalog();
|
||||
let paths = named(&ctl, &[dr_types::ImageId(3), dr_types::ImageId(1)]);
|
||||
assert_eq!(paths, vec!["c.CR2", "a.CR2"]);
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-7
|
||||
#[test]
|
||||
fn a_selection_outside_the_loaded_window_still_resolves() {
|
||||
// The property the catalog lookup exists for. Selection is by id and
|
||||
// survives a scrub, so a selection made before scrolling routinely
|
||||
// names photographs no row holds — and `paths`, the loaded window, is
|
||||
// empty here precisely to prove nothing is being read from it.
|
||||
let ctl = with_catalog();
|
||||
assert!(ctl.paths.borrow().is_empty());
|
||||
assert_eq!(named(&ctl, &[dr_types::ImageId(4)]), ["d.CR2"]);
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-7
|
||||
#[test]
|
||||
fn an_image_that_vanished_under_the_selection_is_skipped() {
|
||||
// Deleted between the selection and the click. There is nothing to
|
||||
// export and nothing to fix, so it drops out rather than becoming a
|
||||
// failure row the user can do nothing about.
|
||||
let ctl = with_catalog();
|
||||
let paths = named(
|
||||
&ctl,
|
||||
&[
|
||||
dr_types::ImageId(1),
|
||||
dr_types::ImageId(99),
|
||||
dr_types::ImageId(2),
|
||||
],
|
||||
);
|
||||
assert_eq!(paths, vec!["a.CR2", "b.CR2"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,6 +674,19 @@ in property <bool> panel-visible: true;
|
||||
in property <string> export-label: "Export";
|
||||
in property <bool> export-busy: false;
|
||||
in property <string> export-status;
|
||||
/// TRACES: FR-EXP-7
|
||||
/// Whether an export would be queued for the server rather than written to
|
||||
/// this device. The grid says the same thing as the develop button, but it
|
||||
/// composes its own label around a selection count — so it is given the
|
||||
/// fact and not the sentence.
|
||||
in property <bool> export-to-server: false;
|
||||
/// Whether a batch is running. The grid's export button becomes its cancel
|
||||
/// button; there is nowhere else a long batch can be stopped from.
|
||||
in property <bool> library-exporting: false;
|
||||
/// Export the grid's selection in the background (FR-EXP-7).
|
||||
callback library-export-selection();
|
||||
/// TRACES: NFR-ARCH-3
|
||||
callback library-cancel-export();
|
||||
/// Show or hide the develop column.
|
||||
callback toggle-panel();
|
||||
/// Export the image on screen, using the settings as they stand.
|
||||
@@ -957,6 +970,12 @@ in property <bool> panel-visible: true;
|
||||
root.paste-settings-to-selection();
|
||||
}
|
||||
|
||||
// TRACES: FR-EXP-7
|
||||
exporting: root.library-exporting;
|
||||
export-to-server: root.export-to-server;
|
||||
export-selection => { root.library-export-selection(); }
|
||||
cancel-export => { root.library-cancel-export(); }
|
||||
|
||||
sweep-done: root.library-sweep-done;
|
||||
sweep-total: root.library-sweep-total;
|
||||
|
||||
|
||||
@@ -456,11 +456,17 @@ component HeaderActions inherits HorizontalLayout {
|
||||
/// Whether settings have been copied, and what pasting them would apply.
|
||||
in property <bool> settings-armed: false;
|
||||
in property <string> settings-summary;
|
||||
/// TRACES: FR-EXP-7
|
||||
/// Whether a batch is running, and where its files are going.
|
||||
in property <bool> exporting: false;
|
||||
in property <bool> export-to-server: false;
|
||||
/// Centres each button in a 44px header. Off in the disclosure row, which
|
||||
/// is sized to its content.
|
||||
in property <bool> centred: true;
|
||||
in property <length> row-height: 44px;
|
||||
|
||||
callback export-selection();
|
||||
callback cancel-export();
|
||||
callback paste-settings-to-selection();
|
||||
callback remove-from-collection();
|
||||
callback change-library();
|
||||
@@ -486,6 +492,37 @@ component HeaderActions inherits HorizontalLayout {
|
||||
clicked => { root.paste-settings-to-selection(); }
|
||||
}
|
||||
|
||||
// TRACES: FR-EXP-7 | NFR-ARCH-3
|
||||
// Export the selection, and stop the batch that is running.
|
||||
//
|
||||
// One button doing both, because they are the same thought a moment apart
|
||||
// and a separate cancel would have to appear from somewhere — shifting the
|
||||
// row under the pointer at the exact moment the user is reaching for it.
|
||||
//
|
||||
// It stays while a batch runs whatever the selection has since become: the
|
||||
// run is what the button now refers to, and a batch of three hundred that
|
||||
// could only be stopped by not changing the selection would be a trap.
|
||||
//
|
||||
// The count is in the label rather than behind a confirmation, exactly as
|
||||
// the paste above puts it there: "Export 40" read before the click is worth
|
||||
// more than a dialogue asking the same question after it.
|
||||
if root.selected-count > 0 || root.exporting: Button {
|
||||
text: root.exporting
|
||||
? "Cancel export"
|
||||
: (root.export-to-server
|
||||
? "Export " + root.selected-count + " to Nextcloud"
|
||||
: "Export " + root.selected-count);
|
||||
active: root.exporting;
|
||||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||
clicked => {
|
||||
if (root.exporting) {
|
||||
root.cancel-export();
|
||||
} else {
|
||||
root.export-selection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Removing from a collection is only meaningful while the grid is scoped
|
||||
// to one. Offering it unscoped would invite the reading "remove from the
|
||||
// library", which nothing here does.
|
||||
@@ -753,6 +790,15 @@ export component LibraryGrid inherits Rectangle {
|
||||
in property <string> settings-summary;
|
||||
callback paste-settings-to-selection();
|
||||
|
||||
// TRACES: FR-EXP-7
|
||||
// Exporting the selection. The grid owns neither the settings that decide
|
||||
// where the files go nor the worker that writes them — it reports what is
|
||||
// selected and asks, exactly as it does for a paste.
|
||||
in property <bool> exporting: false;
|
||||
in property <bool> export-to-server: false;
|
||||
callback export-selection();
|
||||
callback cancel-export();
|
||||
|
||||
// --- the keyboard cursor ------------------------------------------------
|
||||
//
|
||||
// Where the keyboard is in the library, as an **image ordinal** — not a
|
||||
@@ -891,6 +937,10 @@ export component LibraryGrid inherits Rectangle {
|
||||
scope-pinned: root.scope-pinned;
|
||||
settings-armed: root.settings-armed;
|
||||
settings-summary: root.settings-summary;
|
||||
exporting: root.exporting;
|
||||
export-to-server: root.export-to-server;
|
||||
export-selection => { root.export-selection(); }
|
||||
cancel-export => { root.cancel-export(); }
|
||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||
remove-from-collection => { root.remove-from-collection(); }
|
||||
change-library => { root.change-library(); }
|
||||
@@ -938,6 +988,10 @@ export component LibraryGrid inherits Rectangle {
|
||||
scope-pinned: root.scope-pinned;
|
||||
settings-armed: root.settings-armed;
|
||||
settings-summary: root.settings-summary;
|
||||
exporting: root.exporting;
|
||||
export-to-server: root.export-to-server;
|
||||
export-selection => { root.export-selection(); }
|
||||
cancel-export => { root.cancel-export(); }
|
||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||
remove-from-collection => { root.remove-from-collection(); }
|
||||
change-library => { root.change-library(); }
|
||||
|
||||
Reference in New Issue
Block a user