From c41f99ea52f53851eaaa720a4839eea3048bd6e3 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 25 Sep 2026 04:31:23 -0400 Subject: [PATCH] Record the manual's scenes by name, and each against what it depends on scenes.py aimed every press at window pixels, and the develop column had already moved under it: Compose now sits above Adjust, so the old exposure coordinate lands on a straighten slider. Every scene now names what it presses by its accessible label through the automation hook, places points on the photograph relative to the canvas, and opens its photographs by file name. Each starts from a known place and undoes what it did, so one can be recorded alone; the few that continue another's state name it, and running one runs that first into a scratch folder. Each scene also declares the pictures it makes and the sources they depend on. `record.sh --check` fails when the manual shows a picture no scene makes, or a scene makes one it does not show; it reads two files. `record.sh --changed` re-records the scenes whose sources, or own code, changed since the commit that last touched their pictures. record.sh builds with the automation feature, restores the library from DR_LIBRARY_SNAPSHOT, starts from a fresh profile and pins inference to the CPU; the launch screen is recorded from an empty profile of its own. Re-recorded with the ported scenes, and looked at frame by frame. What differs from the pictures they replace: - develop, presets, settings, local, compose, film, wb, light: the current develop column (Compose with Vertical and Horizontal above Adjust, the Label button), otherwise the same moments. - library pictures: the filter bar's colour-label chips; no collection left over from an earlier run in the sidebar; library-selection is the twelve alpine frames rather than eight of them and four New York ones. - library-rating rates two frames nobody had rated, so the stars are set and not cleared. - develop-zoom goes on past 1:1 with the wheel and ends on the file's pixels as hard-edged blocks. - repair covers a real mark on the road, with a size that fits it; film is shown on the Chinatown frame instead of the road. - panorama tries Perspective, Spherical and Cylindrical before filling. - launch, launch-folder and panorama-aligned came out byte-identical. --- docs/manual/media/compose.gif | 4 +- docs/manual/media/develop-groups.gif | 4 +- docs/manual/media/develop-light.gif | 4 +- docs/manual/media/develop-wb.gif | 4 +- docs/manual/media/develop-zoom.gif | 4 +- docs/manual/media/develop.png | 4 +- docs/manual/media/film.gif | 4 +- docs/manual/media/library-collection-menu.png | 4 +- docs/manual/media/library-collections.gif | 4 +- docs/manual/media/library-keywords.gif | 4 +- docs/manual/media/library-nesting.gif | 4 +- docs/manual/media/library-nesting.png | 4 +- docs/manual/media/library-rating.gif | 4 +- docs/manual/media/library-selection.png | 4 +- docs/manual/media/library-thumbsize.gif | 4 +- docs/manual/media/library-timeline.gif | 4 +- docs/manual/media/library.png | 4 +- docs/manual/media/local-categories.png | 4 +- docs/manual/media/local-paint.gif | 4 +- docs/manual/media/local-segment.png | 4 +- docs/manual/media/panorama-filled.png | 4 +- docs/manual/media/panorama.gif | 4 +- docs/manual/media/presets.png | 4 +- docs/manual/media/repair.gif | 4 +- docs/manual/media/settings-export.png | 4 +- docs/manual/media/settings.png | 4 +- tools/manual/README.md | 51 +- tools/manual/drive.py | 27 +- tools/manual/record.sh | 126 +- tools/manual/scenes.py | 1197 ++++++++++++++--- ui/dr-ui/src/automation.rs | 7 +- ui/dr-ui/ui/merge.slint | 2 +- 32 files changed, 1241 insertions(+), 273 deletions(-) diff --git a/docs/manual/media/compose.gif b/docs/manual/media/compose.gif index 0140574..4ea749c 100644 --- a/docs/manual/media/compose.gif +++ b/docs/manual/media/compose.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:181ec1ed2d3de8cdf8dfb4dbb059e55ea1c8231d46b58c75b6eeea430b58ade5 -size 1921098 +oid sha256:de3b5d0e540f295e2d15e8578bb856398f21bb45834592b93bfed499760b70d1 +size 2799326 diff --git a/docs/manual/media/develop-groups.gif b/docs/manual/media/develop-groups.gif index 13ea9c2..403f82f 100644 --- a/docs/manual/media/develop-groups.gif +++ b/docs/manual/media/develop-groups.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:89df272b7ea2fd9d987f9743429a1ce9bb963d39d508b844757771334bc55157 -size 345057 +oid sha256:b8b03cdc279e3b7a3366dad0829b192a9161a23d7c6fc85a262c5d45a694f8c5 +size 343592 diff --git a/docs/manual/media/develop-light.gif b/docs/manual/media/develop-light.gif index b6a322c..de5d98e 100644 --- a/docs/manual/media/develop-light.gif +++ b/docs/manual/media/develop-light.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1887f1640e2dca650ef344cde39f7adbc98196588f96a757cca912954b9c41e8 -size 2925741 +oid sha256:731469768b156c80696d8e6c5cfca2427591fddc5d60b3d0eb220a7e9a88bc65 +size 3136229 diff --git a/docs/manual/media/develop-wb.gif b/docs/manual/media/develop-wb.gif index 327bb03..726eaea 100644 --- a/docs/manual/media/develop-wb.gif +++ b/docs/manual/media/develop-wb.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57e9b181a783b2b00bfd5f1295de7bfcbddb05eaf5826a6a553161d8bb29e28a -size 1801325 +oid sha256:523baf0c5cad46c3874c9fb7a18c3fb47259430ec35ac033e7acebb3e70015eb +size 1797569 diff --git a/docs/manual/media/develop-zoom.gif b/docs/manual/media/develop-zoom.gif index 5ce4cd2..baee305 100644 --- a/docs/manual/media/develop-zoom.gif +++ b/docs/manual/media/develop-zoom.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ffb8620f6874034345ba2d5d9262f5657da02119b634fa2fb46a11bac106246 -size 3559669 +oid sha256:45e275fc7165f25d2a9aea317bc76dc35c7ef2d56dfa00bf5a7b3ae325c12fc9 +size 7778853 diff --git a/docs/manual/media/develop.png b/docs/manual/media/develop.png index 69c67c7..f665a7a 100644 --- a/docs/manual/media/develop.png +++ b/docs/manual/media/develop.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ee6b5672974ec0d7bd2177ae925b60493aac59585f9a8403479e11a819a296e9 -size 1564867 +oid sha256:c2b89da21d4903073db17249106d8ae199e8f8be7d46de9b6e71560f3f6e72fa +size 1566725 diff --git a/docs/manual/media/film.gif b/docs/manual/media/film.gif index 4cf369a..025737a 100644 --- a/docs/manual/media/film.gif +++ b/docs/manual/media/film.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57ed26e716c17a28d8c82b4d4bab2a07c4987e0672ee45c45d6a9b9145c41ea0 -size 723666 +oid sha256:e713496df0b0bd85ed0df6f56c013eb55d519eeeb2a2f6e1a6f2aee8bd345473 +size 1089689 diff --git a/docs/manual/media/library-collection-menu.png b/docs/manual/media/library-collection-menu.png index 5e233ea..3ad14fe 100644 --- a/docs/manual/media/library-collection-menu.png +++ b/docs/manual/media/library-collection-menu.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d39e4d07042839516b1a6eed7a1c126fb7058a9f394f23a3bf019f5209ad01d7 -size 216510 +oid sha256:9d2ffcac1fd94552cc0f5df7fe9a99bfe2313775564f8b25e5b419f9fe328e39 +size 217552 diff --git a/docs/manual/media/library-collections.gif b/docs/manual/media/library-collections.gif index bd071d1..014fb47 100644 --- a/docs/manual/media/library-collections.gif +++ b/docs/manual/media/library-collections.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6eb6c54ac139ee07ba616e99ca672c5b3ee4e4bff472ccc6a1299a05bb488246 -size 701171 +oid sha256:f9a454098b5e6a373a0cfc5f092b84a4cd124c7e2ede463042c17af33189b275 +size 825837 diff --git a/docs/manual/media/library-keywords.gif b/docs/manual/media/library-keywords.gif index 87f5962..eeba4f6 100644 --- a/docs/manual/media/library-keywords.gif +++ b/docs/manual/media/library-keywords.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ee20fd9cdb62734d057a4379821599b17779521073cb116ba19af9b8688e249c -size 401768 +oid sha256:daffda4b7fcdfaa97958bbe1ef498058037d04c39689c6d3a8c8483ee71183d2 +size 613141 diff --git a/docs/manual/media/library-nesting.gif b/docs/manual/media/library-nesting.gif index 2e29e87..0b41dbd 100644 --- a/docs/manual/media/library-nesting.gif +++ b/docs/manual/media/library-nesting.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:306b0d30a9b7a92dd409b6d1a0f973aa0ce7bfeb5a1e31fc1394761c8b967c5a -size 920770 +oid sha256:21a39e445e785c3f347ee9edbc3936b9454482d451e4bf0a4c7c7f5ca4127206 +size 1523363 diff --git a/docs/manual/media/library-nesting.png b/docs/manual/media/library-nesting.png index dbc2411..d172425 100644 --- a/docs/manual/media/library-nesting.png +++ b/docs/manual/media/library-nesting.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b479c2fcf4ba5641accf06b0cee67cbbd406f5905de59ab6f53de137fec2476f -size 357747 +oid sha256:f3f185b442814e968f9626e2e0d7890b9d534c13b4e1a11c425641b6106f3adc +size 358956 diff --git a/docs/manual/media/library-rating.gif b/docs/manual/media/library-rating.gif index a8f8200..e11ce50 100644 --- a/docs/manual/media/library-rating.gif +++ b/docs/manual/media/library-rating.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7ff6dd0d8a953240ae4c27613c81d9cfd8555d401f12b9e0345808bfd61b48d -size 462229 +oid sha256:2934b4d0812895b485b1b17d290c7fde2506f731121b1f98f425abcf553e9a5c +size 487865 diff --git a/docs/manual/media/library-selection.png b/docs/manual/media/library-selection.png index a1c89db..1fcf492 100644 --- a/docs/manual/media/library-selection.png +++ b/docs/manual/media/library-selection.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e7e9c740ede38211c3ce0d70f008a517f82daa13adbc16639b56aef31dc8aad9 -size 994301 +oid sha256:688cec488f5784f27cd9b15b5e2c606f1706d0af5caf59694f15b8eca01f3231 +size 1005493 diff --git a/docs/manual/media/library-thumbsize.gif b/docs/manual/media/library-thumbsize.gif index 0b8ca53..791cfb0 100644 --- a/docs/manual/media/library-thumbsize.gif +++ b/docs/manual/media/library-thumbsize.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0b67b43fffdbc590334aabf8555eb82a8061583f8320b001a5cfdb591ec6dd90 -size 1570486 +oid sha256:4e7cffc7b96de83a6102b0c43330b0fcebbb1fecd87d6008644d52ef75d5fae8 +size 2447955 diff --git a/docs/manual/media/library-timeline.gif b/docs/manual/media/library-timeline.gif index 478d95a..5d07f43 100644 --- a/docs/manual/media/library-timeline.gif +++ b/docs/manual/media/library-timeline.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8f8ff892fc717ac3a01cbff2ee755c5c6a6e0dc80bf43286a9443ee07814f79 -size 926338 +oid sha256:468cc626dd52b3ff715f6750697f1d03b30d6fe4e898a14c2a4d5c341f6c9457 +size 981099 diff --git a/docs/manual/media/library.png b/docs/manual/media/library.png index 0f5db50..3379607 100644 --- a/docs/manual/media/library.png +++ b/docs/manual/media/library.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f65289c8e7054109a5869be200b6c97fe855a31ae3ade36ffe0504f8010cb115 -size 1037382 +oid sha256:fabc074b493fe14ebfb76035e39a8129601784cf3953842bd2c463576c040fd9 +size 1034640 diff --git a/docs/manual/media/local-categories.png b/docs/manual/media/local-categories.png index f9a69a4..f4631e2 100644 --- a/docs/manual/media/local-categories.png +++ b/docs/manual/media/local-categories.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e7890066d24ea7f045e92632bdcd238f74b92896390b802cdd0515bf9bcb2d5 -size 1346470 +oid sha256:d375af25451e665cbd351678efa9b37c4d887ad2d2aad20d66bb1ed868f3a4f2 +size 1348547 diff --git a/docs/manual/media/local-paint.gif b/docs/manual/media/local-paint.gif index 93a9476..2c7cea5 100644 --- a/docs/manual/media/local-paint.gif +++ b/docs/manual/media/local-paint.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:645b45b39722f0cc352289c521f03b5053b55cc61eb776c979f8fa7fa518ad47 -size 399814 +oid sha256:a47508cb130980a3fff9210aec51803646712ee345758caffd99807466fa881d +size 1114099 diff --git a/docs/manual/media/local-segment.png b/docs/manual/media/local-segment.png index cf4502e..5b2d3d8 100644 --- a/docs/manual/media/local-segment.png +++ b/docs/manual/media/local-segment.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2fd560acc54eaf9f537f9b13423a9325c2b2441027ed237f6bbd4da0a7a279b5 -size 1357900 +oid sha256:8e51c942217cd7f0ac791fbb1e01af884dd9ea4465777d97c9480c850b22222b +size 1359531 diff --git a/docs/manual/media/panorama-filled.png b/docs/manual/media/panorama-filled.png index 7974967..278aba9 100644 --- a/docs/manual/media/panorama-filled.png +++ b/docs/manual/media/panorama-filled.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:65c97e0424fa36f32a77dcc789108d92f1e42526e8d887b4864ff0a219299d18 -size 1046096 +oid sha256:66c985ff59d67f4f9761b2563008fa184c2f616a18750f671e7993194812c09d +size 1045211 diff --git a/docs/manual/media/panorama.gif b/docs/manual/media/panorama.gif index b68b49a..772169d 100644 --- a/docs/manual/media/panorama.gif +++ b/docs/manual/media/panorama.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5867f182c9cd8156328710ab21e0ad6b549c1fa7839d472922dba427b6f95b38 -size 902017 +oid sha256:b18f91fbd8a4e7293a881691d518631c94eb2f2b370b221eb1d550de45be4cb2 +size 1322779 diff --git a/docs/manual/media/presets.png b/docs/manual/media/presets.png index 510727c..98638d5 100644 --- a/docs/manual/media/presets.png +++ b/docs/manual/media/presets.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8685d7aebd9abace5fb5247105939fc1d4563fea7cb3a9fd4fec7b900eab7c0 -size 736006 +oid sha256:8874ddc135f359998dcb7b3a047166425f2625027a35c1af878dbbf9c49235c4 +size 720725 diff --git a/docs/manual/media/repair.gif b/docs/manual/media/repair.gif index 458556f..1151d94 100644 --- a/docs/manual/media/repair.gif +++ b/docs/manual/media/repair.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f88bf5622ae40c61d284c741b4f4ecda56c84bc6b7e22371877410c6c231c7a2 -size 249346 +oid sha256:0684dc43c970d320d58f7bf460636783e4d1a8d431783878eec40a7f9f03d650 +size 325697 diff --git a/docs/manual/media/settings-export.png b/docs/manual/media/settings-export.png index 8148b91..e455ee6 100644 --- a/docs/manual/media/settings-export.png +++ b/docs/manual/media/settings-export.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b4db0c848b4dec5b6779a83b3b51b18593f079fb6e128f8d559ee0d10ad14f89 -size 89992 +oid sha256:fdb4c6190111c31e7cf57b76ef134eca36f64bfbcc3ef26c59b1ca79ecf70f4b +size 89970 diff --git a/docs/manual/media/settings.png b/docs/manual/media/settings.png index 0ad5936..e37c0f6 100644 --- a/docs/manual/media/settings.png +++ b/docs/manual/media/settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f37fc703b03e8251facde23098cd78c6e7c41be677491bc1b06604821e58ec8 -size 108703 +oid sha256:ad6972c7e921463fa78313523fbcfa22bdb8e875193228648847b4383227f606 +size 107714 diff --git a/tools/manual/README.md b/tools/manual/README.md index b0eda89..8267b61 100644 --- a/tools/manual/README.md +++ b/tools/manual/README.md @@ -1,20 +1,51 @@ # tools/manual — the pictures in docs/manual -`record.sh ` drives the desktop build on a private X server and -records every scene in `scenes.py` into `docs/manual/media/`. `drive.py` is -the puppeteer underneath — launch, click, drag, type, screenshot, record — -and is usable on its own to look at a panel after changing it: +`record.sh` drives the desktop build on a private X server and records the +scenes in `scenes.py` into `docs/manual/media/`: ```bash -DR_HOME=/var/tmp/x tools/manual/drive.py launch /some/folder -tools/manual/drive.py click 1543 22 # Settings -tools/manual/drive.py shot /tmp/settings.png +tools/manual/record.sh /var/tmp/dr-demo/library # every scene +tools/manual/record.sh /var/tmp/dr-demo/library develop_zoom # one, and what it needs +tools/manual/record.sh --changed /var/tmp/dr-demo/library # those whose sources moved +tools/manual/record.sh --check # the manual and the scenes agree +tools/manual/record.sh --list +``` + +Set `DR_LIBRARY_SNAPSHOT` to a pristine copy of the library: a recording +rates, files and merges, and the snapshot is copied over the library first. +Each run starts from a fresh profile under `DR_HOME`. + +## Controls by name + +The binary is built with `--features automation`, which lets the app answer +"where is the control labelled *Exposure*?" over the Unix socket named by +`DR_AUTOMATION` (`ui/dr-ui/src/automation.rs`). No other build has the +feature, and a build that has it listens only when the variable is set. The +names are the accessible labels a screen reader reads, so a scene says +`click_on('Select@Button')` or `slide('Exposure', 24)` rather than a pixel, +and survives a panel that moves. The pointer itself is still xdotool. + +```bash +DR_HOME=/var/tmp/x tools/manual/drive.py launch /some/folder # a feature build in DR_BIN +tools/manual/drive.py labels # every name on screen +tools/manual/drive.py click-on 'Settings@Button' +tools/manual/drive.py ids canvas # things with no name: id:canvas-image tools/manual/drive.py stop ``` -The demo library is the author's: seventy frames with no people in them, -the `fixtures/pano` set among them. The scenes' cell coordinates are for -that grid, at 1600×1100; the panel coordinates hold for any library. +Grid cells are named by file (`_MG_8393@ListItem`), rating stars `1 star` +to `5 stars`. What a scene cannot name — a point on the photograph, the crop +rectangle's corner — it places relative to something it can +(`drive.photo(fx, fy)`, `id:move-area`). + +## Scenes and the manual + +Each scene in `scenes.py` declares the pictures it makes, as the manual names +them, and the globs of the sources that could change them. `--check` fails +when the manual shows a picture no scene makes, or a scene makes one the +manual does not show; it reads two files and runs in CI. `--changed` +re-records a scene when one of its sources, or its own code, has changed +since the commit that last touched its pictures. Two things the scripts know that are not obvious: a Slint `TouchArea` wants a held press, not xdotool's `click`; and the app is driven on Xvfb rather diff --git a/tools/manual/drive.py b/tools/manual/drive.py index 682db07..a1cc9d1 100755 --- a/tools/manual/drive.py +++ b/tools/manual/drive.py @@ -31,8 +31,10 @@ Suffixes narrow it — `@ROLE` takes only elements of that role (`Button`, `#-1` the last (a sheet is drawn after what it covers). A leading `id:` names an element by its id in the markup or its component type instead (`id:canvas-image`, `id:move-area`, `id:Timeline`), for the few things a -scene aims at that are not controls. Only what Slint draws is answered: -nothing hidden, nothing scrolled out of its list. +scene aims at that are not controls. Only what Slint draws is answered, +and only where its centre is in the window: nothing hidden, nothing +scrolled out of its list. An element half under the edge of a scrolled +list still counts, so a scene that needs all of one scrolls it into view. The input itself is still xdotool — a real pointer, pressed and held — so a scene exercises exactly what a hand would. The hook only says where to aim. @@ -229,6 +231,17 @@ def wait_ready(timeout=120): time.sleep(0.5) +_window = None + + +def window_size(): + global _window + if _window is None: + w = ask('window') + _window = (w['w'], w['h']) + return _window + + def parse_name(name): """`LABEL`, `id:ID`, each with optional `@ROLE` and `#N`.""" m = re.match(r'^(.*?)(?:@(\w+))?(?:#(-?\d+))?$', name, re.S) @@ -244,7 +257,9 @@ def matches(name, within=None): hits = ask(f'locate-id {what[3:]}') else: hits = ask(f'locate {what}') - hits = [e for e in hits if e['w'] > 0 and e['h'] > 0 and e['opacity'] > 0] + W, H = window_size() + hits = [e for e in hits if e['w'] > 0 and e['h'] > 0 and e['opacity'] > 0 + and 0 <= e['x'] + e['w'] / 2 < W and 0 <= e['y'] + e['h'] / 2 < H] if role: hits = [e for e in hits if (e['role'] or '').lower() == role.lower()] if within: @@ -351,7 +366,11 @@ def rec_start(out): def rec_stop(): - pid = int(open(f'{HOME}/rec.pid').read()) + """Stop the recording `rec_start` began. The pid file goes with it, so + a second stop — or one after a scene failed — signals nothing.""" + path = f'{HOME}/rec.pid' + pid = int(open(path).read()) + os.remove(path) os.kill(pid, 2) for _ in range(50): try: diff --git a/tools/manual/record.sh b/tools/manual/record.sh index d1cd52f..8c83961 100755 --- a/tools/manual/record.sh +++ b/tools/manual/record.sh @@ -1,57 +1,145 @@ #!/usr/bin/env bash # Re-record the manual: docs/manual/README.md's pictures, from the app itself. # -# tools/manual/record.sh [scene...] +# tools/manual/record.sh LIBRARY [SCENE...] every scene, or those named +# tools/manual/record.sh --changed LIBRARY the scenes whose sources +# changed since their pictures +# tools/manual/record.sh --check the manual and the scenes +# agree (no app, no display) +# tools/manual/record.sh --list the scenes and their pictures # -# Needs Xvfb, xdotool, ImageMagick's `import` and ffmpeg. Builds the desktop -# binary if it is not there, starts a private X server, opens the folder as -# a library in a throwaway profile, runs the scenes (all of them by default) -# and writes PNGs and GIFs into docs/manual/media/. +# Needs Xvfb, xdotool, ImageMagick's `import`, ffmpeg and optipng. Builds the +# desktop binary with the `automation` feature — the hook scenes.py finds +# controls by name through — unless DR_BIN names one. Starts a private X +# server, opens LIBRARY in a fresh throwaway profile under DR_HOME, runs the +# scenes and writes PNGs and GIFs into docs/manual/media/. # -# The library folder is the author's demo set — seventy face-free frames, -# the twelve-frame panorama from fixtures/pano among them — and scenes.py's -# cell coordinates assume its grid. Another folder needs those looked at. +# LIBRARY is the author's demo set — seventy face-free frames, the +# twelve-frame panorama from fixtures/pano among them — and the scenes open +# its photographs by file name. Recording changes it (ratings, collections, +# the merged DNG), so DR_LIBRARY_SNAPSHOT, if set, is copied over it first. +# +# Environment: DR_HOME (/var/tmp/dr-manual), DR_DISPLAY (:7), DR_BIN, +# DR_LIBRARY_SNAPSHOT, DR_EXPORT_DIR (LIBRARY/../export), DR_INFERENCE +# (`cpu`, the default, pins inference to the CPU so a recording does not +# fight a training run for the GPU; `probe` lets the app choose), CARGO +# (cargo; a wrapper taking cargo's arguments works too). set -euo pipefail here="$(cd "$(dirname "$0")" && pwd)" repo="$(cd "$here/../.." && pwd)" -library="${1:?library folder}" -shift || true +scenes="$here/scenes.py" + +mode=record +case "${1:-}" in + --check) exec python3 "$scenes" check ;; + --list) exec python3 "$scenes" list ;; + --changed) mode=changed; shift ;; + ""|-h|--help) sed -n '2,/^set -e/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;; +esac + +library="$(cd "${1:?library folder}" && pwd)" +shift + +if [ "$mode" = changed ]; then + python3 "$scenes" changed | tee /dev/stderr > /dev/null + mapfile -t chosen < <(python3 "$scenes" changed | cut -f1) + if [ ${#chosen[@]} -eq 0 ]; then + echo "nothing to re-record: every picture is newer than what it depends on" + exit 0 + fi + set -- "${chosen[@]}" +fi export DR_HOME="${DR_HOME:-/var/tmp/dr-manual}" export DR_DISPLAY="${DR_DISPLAY:-:7}" -export DR_BIN="${DR_BIN:-$repo/target/release/darkroom-desktop}" export DR_LIBRARY="$library" +export DR_AUTOMATION="$DR_HOME/automation.sock" media="$repo/docs/manual/media" -mkdir -p "$media" "$DR_HOME/xdg/config/darkroom" "$DR_HOME/xdg/data/darkroom" -[ -x "$DR_BIN" ] || (cd "$repo" && cargo build --release -p darkroom-desktop) +if [ -z "${DR_BIN:-}" ]; then + (cd "$repo" && ${CARGO:-cargo} build --release -p darkroom-desktop --features automation) + DR_BIN="$repo/target/release/darkroom-desktop" +fi +export DR_BIN + +if [ -n "${DR_LIBRARY_SNAPSHOT:-}" ]; then + rsync -a --delete "$DR_LIBRARY_SNAPSHOT/" "$library/" +fi + +# A fresh profile every time, so a scene sees the library and not whatever +# the last run left in the catalog. The inference cache is kept — it is +# minutes of compiling — and so are the links to the models. The marker is +# what makes the wipe safe: nothing without it is deleted. +profile="$DR_HOME/xdg" +if [ -d "$profile" ] && [ ! -e "$DR_HOME/.manual-profile" ]; then + echo "$profile exists and was not made by record.sh; set DR_HOME elsewhere" >&2 + exit 1 +fi +mkdir -p "$DR_HOME" && touch "$DR_HOME/.manual-profile" +rm -rf "$profile/config" "$profile/state" +if [ -d "$profile/data/darkroom" ]; then + find "$profile/data/darkroom" -mindepth 1 -maxdepth 1 \ + ! -name inference ! -name models ! -name runtime -exec rm -rf {} + +fi +mkdir -p "$profile/config/darkroom" "$profile/data/darkroom" # The models and the runtime are shared with the real profile: a segmenter # that is not there makes "Find subjects" a picture of nothing. for d in models runtime; do src="${XDG_DATA_HOME:-$HOME/.local/share}/darkroom/$d" - [ -e "$src" ] && ln -sfn "$src" "$DR_HOME/xdg/data/darkroom/$d" + [ -e "$src" ] && ln -sfn "$src" "$profile/data/darkroom/$d" done +# Keep the inference choice the app last made, but on the CPU unless asked. +backend="$profile/data/darkroom/inference/backend.json" +if [ "${DR_INFERENCE:-cpu}" = cpu ] && [ -e "$backend" ]; then + python3 - "$backend" <<'PY' +import json, sys +p = sys.argv[1] +d = json.load(open(p)) +d['rung'], d['reason'] = 'Cpu', 'forced for the demo' +json.dump(d, open(p, 'w'), indent=2) +PY +fi + # A folder library, chosen: no launch screen on the way in. -cat > "$DR_HOME/xdg/config/darkroom/sessions.json" < "$profile/config/darkroom/sessions.json" < "$profile/config/darkroom/settings.json" </dev/null 2>&1; then Xvfb "$DR_DISPLAY" -screen 0 1920x1200x24 +extension GLX +render -noreset \ > "$DR_HOME/xvfb.log" 2>&1 & echo $! > "$DR_HOME/xvfb.pid" + started_x=1 sleep 2 fi +cleanup() { + python3 "$here/drive.py" stop || true + if [ -n "$started_x" ]; then kill "$(cat "$DR_HOME/xvfb.pid")" || true; fi +} +trap cleanup EXIT +stamp="$DR_HOME/.recording-started" +touch "$stamp" python3 "$here/drive.py" launch -sleep 12 -python3 "$here/scenes.py" "$media" "${@:-all}" -python3 "$here/drive.py" stop +DR_TOOLS="$here" python3 -c ' +import os, sys, time +sys.path.insert(0, os.environ["DR_TOOLS"]) +import drive as dr +dr.wait_ready() +dr.wait_for("id:grid-scroll", 120) +time.sleep(10) # the first thumbnails, and the scan settling +' +python3 "$scenes" record "$media" "${@:-all}" # GIFs for the page; the MP4s are working files and are not kept. for mp4 in "$media"/*.mp4; do @@ -59,4 +147,4 @@ for mp4 in "$media"/*.mp4; do "$here/gif.sh" "$mp4" "${mp4%.mp4}.gif" 960 10 rm "$mp4" done -optipng -quiet -o2 "$media"/*.png || true +find "$media" -name '*.png' -newer "$stamp" -exec optipng -quiet -o2 {} + || true diff --git a/tools/manual/scenes.py b/tools/manual/scenes.py index 17efd4e..d7467d7 100755 --- a/tools/manual/scenes.py +++ b/tools/manual/scenes.py @@ -1,28 +1,112 @@ #!/usr/bin/env python3 -"""The manual's scenes: `scenes.py OUT_DIR scene [scene...]`, or `all`. +"""The manual's scenes, and what each one is for. -Each scene drives the running app through `drive.py` and leaves a PNG or an -MP4 in OUT_DIR, named after itself. `record.sh` runs them in order and turns -the MP4s into GIFs. + scenes.py record OUT_DIR SCENE... drive the running app through the + scenes (`all` for every one) + scenes.py order SCENE... the scenes to run, prerequisites first + scenes.py check does the manual picture what the scenes + make, and only that? (no app needed) + scenes.py changed the scenes whose sources changed since + their pictures were last committed + scenes.py list every scene, its pictures and sources -Coordinates are window pixels for a 1600×1100 window over the manual's own -library (see record.sh): which cell holds which photograph is part of the -scene, so a different library needs the numbers looked at again. Panel -coordinates hold for any library. +Each scene drives the running app through `drive.py` and leaves the +pictures it names in OUT_DIR: PNGs, and MP4s that `record.sh` turns into +the GIFs the manual shows. A scene names its controls by the accessible +label a screen reader reads (see drive.py), so it survives a panel that +moves; what it cannot name — a point on the photograph, the crop's corner +— it places relative to something it can. + +Every scene starts from a known place (`at_library`, `at_develop`) and +leaves the photograph as it found it (`undo_all`), so one can be recorded +on its own. The few that continue another's work say so with `after`, and +running one runs its prerequisites first, into a scratch folder. + +`sources` are the globs of the files whose change could change the +picture; `changed` compares them, and the scene's own code here, against +the commit that last touched each of its pictures. + +The library is the author's demo set (see record.sh): seventy face-free +frames, the twelve-frame alpine panorama among them. The photographs a +scene opens are named by file. """ +import fnmatch +import inspect import os import re import subprocess import sys +import tempfile import time -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, '..', '..')) +MANUAL = 'docs/manual/README.md' +MEDIA = 'docs/manual/media' + +sys.path.insert(0, HERE) import drive as dr # noqa: E402 -OUT = sys.argv[1] if len(sys.argv) > 1 else '.' +OUT = '.' DEMO_LIBRARY = os.environ.get('DR_LIBRARY', '') # the folder the merge writes its DNG into +# The photographs the scenes open, by file. +CHINATOWN = '_MG_8393' # fire escapes, a white air conditioner, a strip of sky +TEAPOT = '_MG_4918' +PANO_FIRST, PANO_LAST = '_MG_8320', '_MG_8331' +NY_FIRST, NY_LAST = '_MG_8393', '_MG_8397' +ALPS_ROW = ['_MG_8322', '_MG_8328'] # the second row of the alps, first and last +TOWER = '_MG_8693' # towers shot looking up: verticals that converge +ROAD = '_MG_8672' # a road under a sky, with marks on it: masks and repair +ROAD_MARK = (0.305, 0.794) # a dark mark on its tarmac +# --- the registry ----------------------------------------------------------- +SCENES = {} + +# What almost every picture depends on: the shared controls and the palette. +COMMON = [ + 'ui/dr-ui/style.yaml', + 'ui/dr-ui/ui/widgets.slint', + 'ui/dr-ui/ui/controls.slint', + 'ui/dr-ui/ui/icons.slint', +] +LIBRARY_SRC = COMMON + [ + 'ui/dr-ui/ui/library.slint', + 'ui/dr-ui/ui/collections.slint', + 'ui/dr-ui/ui/labels.slint', + 'ui/dr-ui/src/library_ui/**', + 'ui/dr-ui/src/library/cells.rs', + 'ui/dr-ui/src/library/filters.rs', +] +DEVELOP_SRC = COMMON + [ + 'ui/dr-ui/ui/app.slint', + 'ui/dr-ui/ui/develop.slint', + 'ui/dr-ui/ui/adjust.slint', + 'ui/dr-ui/ui/toolrail.slint', + 'ui/dr-ui/ui/histogram.slint', + 'ui/dr-ui/src/develop_ui.rs', + 'ui/dr-ui/src/develop/rows.rs', + 'ui/dr-ui/src/develop/tabs.rs', +] +PIPELINE_SRC = ['core/dr-pipeline/src/**', 'core/dr-gpu/src/**'] + + +class Scene: + def __init__(self, fn, media, sources, after): + self.fn, self.name = fn, fn.__name__ + self.media, self.sources, self.after = list(media), list(sources), list(after) + + +def scene(media, sources=(), after=()): + """Register a scene: the pictures it leaves (as the manual names them), + the sources they depend on, and the scenes whose state it continues.""" + def register(fn): + SCENES[fn.__name__] = Scene(fn, media, sources, after) + return fn + return register + + +# --- small verbs ------------------------------------------------------------ def shot(name): dr.shot(f'{OUT}/{name}.png') @@ -39,20 +123,18 @@ def pause(s): time.sleep(s) -def hold(px, py, seconds): - dr.move(px, py) - pause(0.15) - dr.x('mousedown', 1) - pause(seconds) - dr.x('mouseup', 1) - - def wheel(n, px, py): + """`n` notches of the wheel at a point: positive scrolls down.""" dr.move(px, py) dr.x('click', '--repeat', abs(n), '--delay', 40, 5 if n > 0 else 4) def ctrl_wheel(n, px, py): + """`n` notches with Ctrl held: negative (up) makes the thumbnails + larger. The pointer is nudged first — a wheel with no motion before it + can reach the grid without the hover that routes it to the zoom.""" + dr.move(px + 2, py + 2) + pause(0.1) dr.move(px, py) dr.x('keydown', 'ctrl') for _ in range(abs(n)): @@ -61,270 +143,849 @@ def ctrl_wheel(n, px, py): dr.x('keyup', 'ctrl') -# --- places on the screen --------------------------------------------------- -LIBRARY = (55, 22) # ‹ Library, in the develop header -SELECT = (942, 22) # Select / Done, in the library header -SETTINGS_LIB = (1543, 22) -SETTINGS_DEV = (1343, 22) -EXPORT = (720, 22) -BACK = (60, 22) # ‹ Back, on the settings page -RAIL = {'photo': (30, 75), 'compose': (30, 130), 'local': (30, 185), 'repair': (30, 240)} -GROUPS = {'all': 1269, 'optics': 1313, 'light': 1359, 'colour': 1406, 'effects': 1457, 'detail': 1506} -BEFORE = (210, 1045) -RESET_ADJUST = (1565, 741) # "reset" in the Adjust heading, All group, scrolled to top -CELL_TEAPOT = (424, 165) -CELL_PANO_FIRST = (1264, 165) -CELL_CHINATOWN = (963, 525) -CELL_NY_LAST = (1502, 525) -COLLECTION_ROW = (60, 90) +def with_key(key, fn, *args): + dr.x('keydown', key) + try: + fn(*args) + finally: + dr.x('keyup', key) + + +def key(*keys): + dr.x('key', '--delay', 80, *keys) + + +def type_text(text): + dr.x('type', '--delay', 90, text) + + +def mid(r): + return int((r[0] + r[2]) / 2), int((r[1] + r[3]) / 2) + + +def enabled(name): + try: + return dr.find(name)['enabled'] is not False + except dr.NotFound: + return False + + +# --- where things are ------------------------------------------------------- +def at_library(): + """The grid, from wherever the app is: out of develop, settings or a + merge, and out of selecting.""" + for _ in range(6): + if dr.present('id:grid-scroll'): + break + for back in ('‹ Library@Button', '‹ Back@Button'): + if dr.present(back): + dr.click_on(back) + break + pause(1.5) + dr.wait_for('id:grid-scroll', 30) + if dr.present('Clear@Button'): # the photograph last opened stays selected + dr.click_on('Clear@Button') + pause(0.6) + stop_selecting() + + +HEADER = (0, 0, 10000, 44) + + +def selecting(): + """Whether the grid is selecting: `Select` in the header has become + `Done`.""" + return dr.present('Done@Button', within=HEADER) + + +def start_selecting(): + if not selecting(): + dr.click_on('Select@Button', within=HEADER) + dr.wait_for('Done@Button', 5, within=HEADER) + + +def stop_selecting(): + if selecting(): + dr.click_on('Done@Button', within=HEADER) + dr.wait_for('Select@Button', 5, within=HEADER) + + +def grid_top(): + wheel(-60, *mid(dr.rect('id:grid-scroll'))) + pause(0.6) + + +def grid_view(): + """The part of the grid a cell can be pressed in: not under the + selection bar.""" + x0, y0, x1, y1 = dr.rect('id:grid-scroll') + return x0, y0, x1, y1 - (50 if dr.present('Select all@Button') else 0) + + +def show_cell(name): + """Scroll the grid until the cell for file `name` is wholly in view, + and return its rectangle.""" + x0, y0, x1, y1 = grid_view() + for _ in range(60): + hits = dr.matches(f'{name}@ListItem') + if hits: + e = hits[0] + if e['y'] >= y0 and e['y'] + e['h'] <= y1: + return e['x'], e['y'], e['x'] + e['w'], e['y'] + e['h'] + step = 2 if e['y'] + e['h'] > y1 else -2 + else: + step = 3 + wheel(step, (x0 + x1) // 2, (y0 + y1) // 2) + pause(0.4) + raise dr.NotFound(f'no cell for {name}') + + +def cell(name): + """A point on `name`'s thumbnail, scrolled into view: above the middle, + clear of the star strip that a small cell draws across its centre.""" + x0, y0, x1, y1 = show_cell(name) + return (x0 + x1) // 2, int(y0 + (y1 - y0) * 0.35) + + +def cell_width(): + """How wide the grid's cells are drawn, from whichever is on screen.""" + return next(e['w'] for e in dr.ask('labels _MG_') if e['role'] == 'ListItem') + + +def at_develop(photo=CHINATOWN): + """`photo` open in develop, on the Photo tool, the All group, the + column at its top, and the edit as it was opened.""" + if dr.present('‹ Library@Button') and dr.present(f'{photo}.CR2'): + pass + else: + at_library() + grid_top() + dr.click(*cell(photo)) + dr.wait_for(f'{photo}.CR2', 30) + wait_canvas() + for done in ('Done Composing', 'Done Masking', 'Done Repairing'): + if dr.present(f'{done}@Button'): + dr.click_on(f'{done}@Button') + pause(0.8) + tool('Photo') + undo_all() + group('All') + column_top() + + +def wait_canvas(timeout=30): + """Until the canvas holds a picture.""" + t0 = time.time() + while time.time() - t0 < timeout: + if dr.present('id:canvas-image') and dr.ask('canvas')['w'] > 0: + pause(1.5) + return + pause(0.5) + raise TimeoutError('the photograph never arrived') + + +def undo_all(): + """Walk the history back to where the photograph was opened.""" + for _ in range(80): + if not enabled('Undo@Button#0'): + return + dr.click_on('Undo@Button#0') + pause(0.35) + + +def tool(name): + """A tool from the rail: Photo, Compose, Local, Repair.""" + dr.click_on(f'{name}@Button', within=dr.rect('id:rail')) + pause(1.2) def group(name): - dr.click(GROUPS[name], 67) + """A group from the strip at the head of the develop column.""" + dr.click_on(f'{name}@Text', within=dr.rect('id:GroupStrip')) pause(0.9) -def to_library(): - dr.click(*LIBRARY) - pause(2) +def column(): + return dr.rect('id:develop-column') -def open_chinatown(): - dr.click(*CELL_CHINATOWN) - pause(6) +def column_top(): + wheel(-60, *mid(column())) + pause(0.6) -def reset_edit(): - group('all') - wheel(-60, 1420, 700) - dr.click(*RESET_ADJUST) +def in_column(name, margin=40): + """Scroll the develop column until `name` is well inside it; its centre. + Looks where the column is first, then from the top down.""" + x0, y0, x1, y1 = column() + from_top = False + for _ in range(80): + hits = dr.matches(name, within=(x0, y0, x1, y1)) + if hits: + e = hits[parse_index(name)] + cy = e['y'] + e['h'] / 2 + if y0 + margin <= cy <= y1 - margin: + return int(e['x'] + e['w'] / 2), int(cy) + step = 2 if cy > y1 - margin else -2 + elif not from_top: + column_top() + from_top = True + continue + else: + step = 3 + wheel(step, (x0 + x1) // 2, (y0 + y1) // 2) + pause(0.3) + raise dr.NotFound(f'{name} is not in the develop column') + + +def parse_index(name): + return dr.parse_name(name)[2] + + +def handle(e): + """Where a slider's handle is: its value along the track, read from the + number its readout starts with and the range it reports. The middle + when the readout is not a number.""" + m = re.match(r'\s*(-?[\d.]+)', e['value'] or '') + lo, hi = e.get('min'), e.get('max') + f = 0.5 + if m and lo is not None and hi is not None and hi > lo: + f = min(max((float(m.group(1)) - lo) / (hi - lo), 0.0), 1.0) + return int(e['x'] + e['w'] * f), int(e['y'] + e['h'] / 2) + + +def slide(name, dx, steps=25): + """Drag slider `name` from its handle by `dx`.""" + in_column(f'{name}@Slider') + x0, y0, x1, y1 = column() + cx, cy = handle(dr.find(f'{name}@Slider', within=(x0, y0, x1, y1))) + dr.drag(cx, cy, cx + dx, cy, steps) + + +def hold(px, py, seconds): + dr.move(px, py) + pause(0.15) + dr.x('mousedown', 1) + pause(seconds) + dr.x('mouseup', 1) + + +def hold_before(seconds): + hold(*dr.centre('Before@Button'), seconds) + + +def photo_drag(a, b, steps=25): + dr.drag(*dr.photo(*a), *dr.photo(*b), steps) + + +def reset_adjust(): + in_column('Reset all adjustments') + dr.click_on('Reset all adjustments') pause(1) +# --- opening a library ------------------------------------------------------ +@scene(media=['launch.png', 'launch-folder.png'], + sources=COMMON + ['ui/dr-ui/ui/launch.slint', 'ui/dr-ui/src/launch_ui.rs', + 'ui/dr-ui/src/launch.rs']) +def launch(): + """The launch screen belongs to a profile with no library, so this one + is its own: the app is restarted on an empty profile beside the real + one, shown the demo folder, and put back.""" + empty = f'{dr.HOME}/launch-xdg' + subprocess.run(['rm', '-rf', empty], check=True) + os.makedirs(f'{empty}/config/darkroom', exist_ok=True) + dr.stop() + dr.launch([], xdg=empty) + dr.wait_ready() + dr.wait_for('Open folder@Button', 30) + pause(1) + shot('launch') + dr.click_on('Library folder@TextInput') + type_text(DEMO_LIBRARY or '/var/tmp/dr-demo/library') + dr.click_on('Open folder@Button') + dr.wait_for('Open library@Button', 30) + pause(1) + shot('launch-folder') + dr.stop() + dr.launch([]) + dr.wait_ready() + dr.wait_for('id:grid-scroll', 60) + pause(3) + + # --- library ---------------------------------------------------------------- +@scene(media=[], sources=[]) +def tidy(): + """Not a picture: the demo library's snapshot carries the collections + an earlier recording made, and the collection scenes make them again. + Delete them through the menu, the way anyone would.""" + at_library() + x0, y0, x1, y1 = dr.rect('id:CollectionsPanel') + for _ in range(12): + rows = [e for e in dr.ask('labels ') + if e['role'] == 'Text' and x0 <= e['x'] < x1 and 70 < e['y'] < y1 - 80 + and e['label'] in ('Alps', 'Trips', 'New York')] + if not rows: + return + dr.click(int(rows[0]['x'] + 20), int(rows[0]['y'] + rows[0]['h'] / 2), 3) + dr.wait_for('Delete@Button', 5) + dr.click_on('Delete@Button') + pause(0.8) + if dr.present('Delete anyway@Button'): + dr.click_on('Delete anyway@Button') + pause(1.2) + + +@scene(media=['library.png'], sources=LIBRARY_SRC, after=['tidy']) def library(): + at_library() + grid_top() + dr.move(*mid(dr.rect('id:CollectionsPanel'))) + pause(1) shot('library') +@scene(media=['library-rating.gif'], sources=LIBRARY_SRC) def library_rating(): + """Two photographs rated from the stars on their cells, then the grid + filtered to three stars and more, and back. Frames the demo library + leaves unrated, so a click sets a rating rather than clearing one.""" + at_library() + grid_top() + show_cell('_MG_8674') rec('library-rating') - dr.move(424, 900); pause(1.0) - dr.click(444, 939); pause(1.2) - dr.move(604, 900); pause(0.8) - dr.click(644, 939); pause(1.2) - dr.move(800, 700); pause(0.6) - dr.click(665, 61); pause(1.5) # 3+ - dr.click(384, 61); pause(1.2) # All + for name, stars in (('_MG_8672', '4 stars'), ('_MG_8674', '5 stars')): + c = show_cell(name) + dr.move(*mid(c)) + pause(1.0) + dr.click_on(f'{stars}@Button', within=c) + pause(1.2) + dr.move(*mid(dr.rect('id:CollectionsPanel'))) + pause(0.6) + dr.click_on('3+@Button') + pause(1.5) + dr.click_on('All@Button') + pause(1.2) cut() +@scene(media=['library-timeline.gif'], sources=LIBRARY_SRC) def library_timeline(): + at_library() + grid_top() + x0, y0, x1, y1 = dr.rect('id:Timeline') + x = (x0 + x1) // 2 rec('library-timeline') - dr.drag(300, 200, 300, 900, 40); pause(1.0) - dr.drag(300, 900, 300, 150, 40); pause(1.0) + dr.drag(x, y0 + 120, x, y1 - 120, 40) + pause(1.0) + dr.drag(x, y1 - 120, x, y0 + 70, 40) + pause(1.0) cut() +@scene(media=['library-thumbsize.gif'], sources=LIBRARY_SRC) def library_thumbsize(): + at_library() + grid_top() + centre = mid(dr.rect('id:grid-scroll')) + before = cell_width() rec('library-thumbsize') - ctrl_wheel(-4, 900, 500); pause(0.8) - ctrl_wheel(4, 900, 500); pause(0.8) + ctrl_wheel(-3, *centre) + pause(0.8) + ctrl_wheel(3, *centre) + pause(0.8) cut() + # A notch can go astray, and every scene after this one expects the + # cells the size they were. + for _ in range(12): + pause(0.8) + now = cell_width() + if now == before: + break + ctrl_wheel(-1 if now < before else 1, *centre) + grid_top() +def select_pano(): + """The twelve alpine frames: the first, shift to the last.""" + at_library() + grid_top() + start_selecting() + dr.click(*cell(PANO_FIRST)) + pause(0.3) + with_key('shift', dr.click, *cell(PANO_LAST)) + pause(0.8) + + +@scene(media=['library-selection.png'], sources=LIBRARY_SRC) def library_selection(): - # The grid remembers its scroll between launches; the cells below are - # the grid at its top. - wheel(-40, 900, 500); pause(0.6) - dr.click(*SELECT); pause(0.5) - dr.click(*CELL_PANO_FIRST); pause(0.3) - dr.x('keydown', 'shift'); dr.click(*CELL_NY_LAST); dr.x('keyup', 'shift'); pause(0.8) - for x in (963, 1143, 1323, 1502): - dr.x('keydown', 'ctrl'); dr.click(x, 525); dr.x('keyup', 'ctrl'); pause(0.3) + """The twelve alpine frames: a range with Shift, then the last three + added one at a time with Ctrl.""" + at_library() + grid_top() + start_selecting() + dr.click(*cell(PANO_FIRST)) + pause(0.3) + with_key('shift', dr.click, *cell(ALPS_ROW[1])) + pause(0.8) + for name in ('_MG_8329', '_MG_8330', '_MG_8331'): + with_key('ctrl', dr.click, *cell(name)) + pause(0.3) pause(0.5) shot('library-selection') + stop_selecting() +@scene(media=['library-keywords.gif'], sources=LIBRARY_SRC + ['ui/dr-ui/src/library_ui/ratings_keywords.rs']) def library_keywords(): - # Continues from library_selection: twelve frames selected. + select_pano() rec('library-keywords') - dr.click(1117, 1079); pause(1.2) + dr.click_on('Keywords@Button') + pause(1.2) for word in ('alps', 'panorama', 'summer'): - dr.x('type', '--delay', 90, word); dr.x('key', 'Return'); pause(0.7) + type_text(word) + dr.x('key', 'Return') + pause(0.7) pause(0.8) - dr.click(800, 673); pause(1.0) # Done + dr.click_on('Done@Button#-1') # the sheet's, not the header's + pause(1.0) cut() - dr.click(*SELECT); pause(0.5) # leave selecting + stop_selecting() + + +def collection_row(name): + """The middle of `name`'s row in the sidebar, once the tree has stopped + moving: it re-sorts after a drop or a rename a moment after the gesture, + and a row read mid-way is the neighbour's by the time it is pressed.""" + x0, y0, x1, y1 = dr.rect('id:CollectionsPanel') + dr.wait_for(f'{name}@Text', 15, within=(x0, y0 + 60, x1, y1 - 80)) + last = None + for _ in range(20): + now = dr.centre(f'{name}@Text', within=(x0, y0 + 60, x1, y1 - 80)) + if now == last: + return now + last = now + pause(0.4) + return last + + +def tree_drag(what, onto): + """Drag one sidebar row onto another, slowly enough for the tree's drag + to start under a loaded machine, and until the tree shows it nested.""" + a, b = collection_row(what), collection_row(onto) + dr.x('windowfocus', '--sync', dr.win(), check=False) + dr.move(*a) + pause(0.3) + dr.x('mousedown', 1) + pause(0.4) + for i in range(1, 41): + dr.move(a[0] + (b[0] - a[0]) * i // 40, a[1] + (b[1] - a[1]) * i // 40) + pause(0.03) + pause(0.5) + dr.x('mouseup', 1) + x0, y0, x1, y1 = dr.rect('id:CollectionsPanel') + panel = (x0, y0 + 60, x1, y1 - 80) + t0 = time.time() + while time.time() - t0 < 15: # until the tree draws it inside + if dr.find(f'{what}@Text', within=panel)['x'] > dr.find(f'{onto}@Text', within=panel)['x']: + break + pause(0.4) + pause(1.0) + collection_row(what) + + +def open_menu(name): + """The menu on collection `name`. A right-click that lands just after a + drag in the tree can open the menu of the row that was dragged, so the + sheet's heading is checked and the click tried again.""" + for _ in range(4): + pause(0.8) + dr.click(*collection_row(name), 3) + dr.wait_for('New collection inside@Button', 5) + pause(0.6) + x0, x1 = dr.rect('New collection inside@Button')[0::2] + heads = [e for e in dr.matches(f'{name}@Text') if x0 <= e['x'] < x1] + if heads: + return + dr.click_on('Cancel@Button') + raise dr.NotFound(f'the menu on {name} would not open') def new_collection(name): # `+` makes a collection inside whichever row is selected, so select the # top first for a top-level one. - dr.click(60, 48); pause(0.5) - dr.click(212, 22); pause(0.4) - dr.x('type', '--delay', 90, name); dr.x('key', 'Return'); pause(1.2) - - -# The menu a right-click on a collection opens, at 1600×1100. -MENU = {'rename': (800, 496), 'inside': (800, 536), 'top': (800, 576), - 'offline': (800, 616), 'delete': (800, 669), 'cancel': (800, 709)} + dr.click_on('All photographs@Text') + pause(0.5) + dr.click_on('New collection@Button') + pause(0.4) + type_text(name) + dr.x('key', 'Return') + pause(1.2) +@scene(media=['library-collections.gif'], sources=LIBRARY_SRC + ['ui/dr-ui/src/collections_ui/**'], + after=['tidy']) def library_collections(): + at_library() + grid_top() new_collection('Alps') rec('library-collections') - dr.drag(*CELL_PANO_FIRST, *COLLECTION_ROW, 40); pause(1.5) - dr.click(*SELECT); pause(0.5) - dr.click(424, 350); pause(0.3) - dr.x('keydown', 'shift'); dr.click(1502, 350); dr.x('keyup', 'shift'); pause(0.8) - dr.drag(963, 350, *COLLECTION_ROW, 40); pause(1.5) - dr.click(*SELECT); pause(0.5) - dr.click(*COLLECTION_ROW); pause(1.5) + dr.drag(*cell(PANO_FIRST), *collection_row('Alps'), 40) + pause(1.5) + start_selecting() + dr.click(*cell(ALPS_ROW[0])) + pause(0.3) + with_key('shift', dr.click, *cell(ALPS_ROW[1])) + pause(0.8) + dr.drag(*cell('_MG_8325'), *collection_row('Alps'), 40) + pause(1.5) + stop_selecting() + dr.click(*collection_row('Alps')) + pause(1.5) cut() - dr.click(60, 48); pause(1.2) # All photographs + dr.click_on('All photographs@Text') + pause(1.2) +@scene(media=['library-nesting.gif', 'library-nesting.png', 'library-collection-menu.png'], + sources=LIBRARY_SRC + ['ui/dr-ui/src/collections_ui/**'], after=['library_collections']) def library_nesting(): - # Continues from library_collections: "Alps" exists with photographs in - # it. Make "Trips", drag Alps into it, make "New York" inside Trips from - # the menu, file frames there, then open Trips to see it count both. + """Continues from library_collections: "Alps" exists with photographs + in it. Make "Trips", drag Alps into it, make "New York" inside Trips + from the menu, file frames there, then open Trips to see it count both.""" + at_library() + grid_top() new_collection('Trips') rec('library-nesting') pause(0.5) - dr.drag(60, 90, 60, 128, 30); pause(1.5) # Alps onto Trips - dr.click(60, 90, 3); pause(1.2) # the menu, on Trips - dr.click(*MENU['inside']); pause(0.6) - dr.x('type', '--delay', 90, 'New York'); dr.x('key', 'Return'); pause(1.2) - dr.click(*SELECT); pause(0.5) - dr.click(963, 525); pause(0.3) - dr.x('keydown', 'shift'); dr.click(1502, 525); dr.x('keyup', 'shift'); pause(0.8) - dr.drag_path([(1143, 525), (200, 525), (90, 146)], 25); pause(1.5) # four frames into New York - dr.click(*SELECT); pause(0.5) - dr.click(60, 90); pause(1.5) # Trips: both children + tree_drag('Alps', 'Trips') + open_menu('Trips') + dr.click_on('New collection inside@Button') + pause(0.6) + type_text('New York') + dr.x('key', 'Return') + pause(1.2) + start_selecting() + dr.click(*cell(NY_FIRST)) + pause(0.3) + with_key('shift', dr.click, *cell(NY_LAST)) + pause(0.8) + # Out of the grid sideways first, then to the row: a diagonal with much + # vertical in it is taken by the grid's Flickable as a scroll. + start = cell('_MG_8394') + gx0 = dr.rect('id:grid-scroll')[0] + dr.drag_path([start, (gx0 - 130, start[1]), collection_row('New York')], 25) + pause(1.5) + stop_selecting() + dr.click(*collection_row('Trips')) + pause(1.5) cut() shot('library-nesting') - dr.click(90, 146, 3); pause(1.2) # the menu, on New York + open_menu('New York') + pause(0.4) shot('library-collection-menu') - dr.click(*MENU['cancel']); pause(0.8) - dr.click(60, 48); pause(1.2) + dr.click_on('Cancel@Button') + pause(0.8) + dr.click_on('All photographs@Text') + pause(1.2) + + +@scene(media=['library-labels.gif'], sources=LIBRARY_SRC + ['ui/dr-ui/src/labels.rs']) +def library_labels(): + """Colour labels from the keyboard over four New York frames, then the + grid filtered to one of them, and back.""" + # The grid takes the keyboard when it is built, and a sheet or a rename + # field that had it since does not give it back; opening a photograph + # and coming back builds the grid again. + at_develop(TEAPOT) + at_library() + grid_top() + frames = ['_MG_8423', '_MG_8430', '_MG_8439', '_MG_8440'] + show_cell(frames[-1]) + rec('library-labels') + pause(0.5) + for f, k in zip(frames, ('6', '7', '8', '9')): + dr.move(*cell(f)) + pause(0.6) + key(k) + pause(0.8) + dr.move(*cell(frames[1])) + pause(0.4) + key('7') # the same key again takes it off + pause(1.0) + dr.move(*mid(dr.rect('id:CollectionsPanel'))) + dr.click_on('Green@Button') + pause(1.8) + dr.click_on('Green@Button') + pause(1.2) + cut() + for f, k in zip(frames, ('6', None, '8', '9')): # and take them off again + if k: + dr.move(*cell(f)) + pause(0.3) + key(k) + pause(0.4) # --- develop ---------------------------------------------------------------- +@scene(media=['develop.png'], sources=DEVELOP_SRC) def develop(): - open_chinatown() - group('all') + at_develop() + dr.move(*dr.photo(0.5, 0.5)) + pause(1) shot('develop') +@scene(media=['develop-groups.gif'], sources=DEVELOP_SRC) def develop_groups(): + at_develop() rec('develop-groups') - for g in ['optics', 'light', 'colour', 'effects', 'detail', 'all']: - group(g); pause(0.6) + for g in ['Optics', 'Light', 'Colour', 'Effects', 'Detail', 'All']: + group(g) + pause(0.6) cut() +@scene(media=['develop-light.gif'], sources=DEVELOP_SRC + PIPELINE_SRC) def develop_light(): - group('light') + at_develop() + group('Light') + in_column('Shadows@Slider') rec('develop-light') pause(0.5) - dr.drag(1398, 792, 1422, 792, 25); pause(0.8) # exposure up - dr.drag(1398, 918, 1320, 918, 25); pause(0.8) # highlights down - dr.drag(1398, 964, 1450, 964, 25); pause(0.8) # shadows up - hold(*BEFORE, 1.6); pause(1.0) + slide('Exposure', 24) + pause(0.8) + slide('Highlights', -78) + pause(0.8) + slide('Shadows', 52) + pause(0.8) + hold_before(1.6) + pause(1.0) cut() - reset_edit() + undo_all() +@scene(media=['develop-zoom.gif'], sources=DEVELOP_SRC + ['ui/dr-ui/src/develop/render.rs']) def develop_zoom(): + """1:1 with a double-click, a pan, and then on in with the wheel until + the file's own pixels are hard-edged blocks — which is where it ends.""" + at_develop() + a = dr.photo(0.62, 0.52) # the fire escape and the air conditioner rec('develop-zoom') - dr.move(650, 500); pause(0.3) - dr.x('click', '--repeat', 2, '--delay', 80, 1); pause(1.5) - dr.drag(650, 500, 900, 700, 30); pause(0.8) - dr.drag(900, 700, 500, 450, 30); pause(0.8) - dr.x('click', '--repeat', 2, '--delay', 80, 1); pause(1.2) + dr.move(*a) + pause(0.3) + dr.x('click', '--repeat', 2, '--delay', 80, 1) + pause(1.5) + dr.drag(a[0], a[1], a[0] + 250, a[1] + 200, 30) + pause(0.8) + dr.drag(a[0] + 250, a[1] + 200, a[0] - 150, a[1] - 50, 30) + pause(0.8) + for _ in range(6): + wheel(-1, *a) + pause(0.6) + pause(2.0) cut() + dr.move(*a) + dr.x('click', '--repeat', 2, '--delay', 80, 1) + pause(1.2) +@scene(media=['develop-wb.gif'], sources=DEVELOP_SRC + ['ui/dr-ui/src/develop/white_balance.rs']) def develop_wb(): - group('colour') + at_develop() + group('Colour') + in_column('Temperature@Slider') rec('develop-wb') pause(0.4) - dr.drag(1398, 826, 1330, 826, 25); pause(1.2) # temperature cold - dr.click(1487, 776); pause(0.8) # pick - dr.click(995, 635); pause(1.5) # the white air conditioner - hold(*BEFORE, 1.4); pause(0.8) + slide('Temperature', -68) + pause(1.2) + dr.click_on('Set from a neutral in the photograph') + pause(0.8) + dr.click(*dr.photo(0.8, 0.585)) # the white air conditioner + pause(1.5) + hold_before(1.4) + pause(0.8) cut() - reset_edit() + undo_all() +COMPOSE_SRC = DEVELOP_SRC + ['ui/dr-ui/ui/crop.slint', 'ui/dr-ui/src/develop/framing.rs'] + + +@scene(media=['compose.gif'], sources=COMPOSE_SRC) def compose(): - dr.click(*RAIL['compose']); pause(1.2) + at_develop() + tool('Compose') + x0, y0, x1, y1 = dr.rect('id:move-area') rec('compose') pause(0.4) - dr.drag(66, 182, 260, 330, 30); pause(0.8) - dr.drag(1236, 964, 1100, 900, 25); pause(0.8) - dr.drag(1420, 593, 1448, 593, 20); pause(1.0) # straighten - dr.click(1466, 647); pause(1.2) # 1:1 - dr.click(1378, 647); pause(1.0) # Original - dr.click(132, 1045); pause(1.2) # Done composing + dr.drag(x0 + 6, y0 + 3, x0 + 200, y0 + 151, 30) + pause(0.8) + x0, y0, x1, y1 = dr.rect('id:move-area') + dr.drag(x1 - 4, y1 - 1, x1 - 140, y1 - 65, 25) + pause(0.8) + slide('Straighten', 28, 20) + pause(1.0) + dr.click_on('1:1@RadioButton') + pause(1.2) + dr.click_on('Original@RadioButton') + pause(1.0) + dr.click_on('Done Composing@Button') + pause(1.2) cut() - shot('compose-done') - dr.click(1567, 510); pause(1.0) # reset compose - dr.click(115, 1045); pause(1.0) # refit + undo_all() +@scene(media=['compose-perspective.gif'], sources=COMPOSE_SRC) +def compose_perspective(): + """Towers shot looking up: their walls lean in. Vertical at about +58 + stands them and the lamp posts upright, and the crop refits to what + is left.""" + at_develop(TOWER) + tool('Compose') + in_column('Horizontal@Slider') + rec('compose-perspective') + pause(1.0) + slide('Vertical', 105, 30) + pause(2.0) + dr.click_on('Done Composing@Button') + pause(1.5) + hold_before(1.4) + pause(1.0) + cut() + undo_all() + + +@scene(media=['local-categories.png', 'local-segment.png'], + sources=DEVELOP_SRC + ['ui/dr-ui/ui/masks.slint', 'ui/dr-ui/src/masks_ui.rs', + 'ui/dr-ui/src/segmentation.rs', 'ui/dr-ui/src/develop/segmentation.rs']) def local_segment(): - dr.click(*RAIL['local']); pause(1.2) - dr.click(1420, 435); pause(14) # Find subjects + at_develop(ROAD) + tool('Local') + if dr.present('Find subjects@Button'): # not once the photograph has been looked at + dr.click(*in_column('Find subjects@Button')) + dr.wait_for('Sky@Text', 120) + pause(2) shot('local-categories') - dr.click(1266, 646); pause(2.5) # Sky + dr.click(*in_column('Sky@Text')) + pause(2.5) shot('local-segment') +MASK_SRC = DEVELOP_SRC + ['ui/dr-ui/ui/masks.slint', 'ui/dr-ui/src/masks_ui.rs', + 'ui/dr-ui/src/develop/masks.rs', 'ui/dr-ui/src/develop/mask_ops.rs'] + + +@scene(media=['local-paint.gif'], sources=MASK_SRC, after=['local_segment']) def local_paint(): - # Continues from local_segment: the sky mask selected and shown. + """Continues from local_segment: the sky mask selected and shown. The + three ways of looking at it, a stroke painted into it, then its edge + grown.""" rec('local-paint') - wheel(12, 1420, 800); pause(0.8) - shot('local-mask-row') + pause(0.5) + for how in ('Alpha', 'Edge', 'Tint'): + dr.click(*in_column(f'{how}@RadioButton')) + pause(1.4) + dr.click(*in_column('+ Add@Button')) + pause(1.0) + photo_drag((0.33, 0.28), (0.5, 0.36), 30) + pause(1.5) + dr.click(*in_column('category@Text')) # the sky's own part, whose edge it is + pause(0.8) + dr.click(*in_column('Grow@RadioButton')) + pause(2.0) cut() + tool('Photo') + undo_all() -def local_done(): - dr.click(62, 1045); pause(1.0) # Done masking - reset_edit() +@scene(media=['local-intersect.gif'], sources=MASK_SRC) +def local_intersect(): + """A gradient over the lower half, then `∩ Intersect` and two strokes: + the mask keeps only where the strokes and the gradient agree.""" + at_develop() + tool('Local') + rec('local-intersect') + pause(0.5) + dr.click(*in_column('Linear@Button')) + pause(1.8) + dr.click(*in_column('∩ Intersect@Button')) + pause(1.2) + photo_drag((0.6, 0.15), (0.6, 0.95), 30) + pause(0.8) + photo_drag((0.7, 0.95), (0.7, 0.15), 30) + pause(2.0) + cut() + tool('Photo') + undo_all() +@scene(media=['crop-orphan.gif'], sources=COMPOSE_SRC + MASK_SRC) +def crop_orphan(): + """A stroke in the top-left corner, then a crop that leaves it outside + the frame: the notice says so and offers the way back.""" + at_develop() + tool('Local') + dr.click(*in_column('Paint@Button')) + pause(1.0) + photo_drag((0.04, 0.07), (0.16, 0.13), 20) + pause(1.0) + tool('Compose') + x0, y0, x1, y1 = dr.rect('id:move-area') + rec('crop-orphan') + pause(0.8) + dr.drag(x0 + 3, y0 + 3, *dr.photo(0.3, 0.3), 30) + dr.wait_for('Undo crop@Button', 10) + pause(2.5) + dr.click_on('Undo crop@Button') + pause(2.0) + cut() + tool('Photo') + undo_all() + + +@scene(media=['repair.gif'], sources=DEVELOP_SRC + ['ui/dr-ui/ui/spots.slint', 'ui/dr-ui/src/spots_ui.rs', + 'ui/dr-ui/src/develop/repairs.rs']) def repair(): - dr.click(*RAIL['repair']); pause(1.2) + at_develop(ROAD) + tool('Repair') rec('repair') pause(0.4) - dr.click(700, 620); pause(3) - dr.drag(1264, 460, 1330, 460, 20); pause(1.5) # size - hold(*BEFORE, 1.4); pause(0.8) + dr.click(*dr.photo(*ROAD_MARK)) + pause(3) + slide('Size', 20, 20) + pause(1.5) + hold_before(1.4) + pause(0.8) cut() - dr.click(125, 1045); pause(1.0) # Done repairing - reset_edit() + tool('Photo') + undo_all() +@scene(media=['film.gif'], sources=DEVELOP_SRC + ['core/dr-film/src/**']) def film(): - group('all') + at_develop() rec('film') - dr.click(1420, 779); pause(1.5) # Film ▸ - dr.click(1420, 953); pause(3) # Velvia 100 - hold(*BEFORE, 1.4); pause(0.8) + dr.click(*in_column('Film@Text')) + pause(1.5) + dr.click(*in_column('Fujifilm Velvia 100@Text')) + pause(3) + hold_before(1.4) + pause(0.8) cut() - reset_edit() + undo_all() +@scene(media=['presets.png'], sources=DEVELOP_SRC + ['ui/dr-ui/ui/presets.slint', 'ui/dr-ui/src/presets.rs']) def presets(): - dr.click(1420, 692); pause(1.5) + at_develop() + dr.click_on('Presets…@Button') + dr.wait_for('CARRIES@Text', 5) + pause(1.5) shot('presets') - dr.click(800, 783); pause(1.0) # Done - - -def develop_export(): - dr.click(*EXPORT); pause(8) - shot('export-done') + dr.click_on('Done@Button#-1') + pause(1.0) # --- panorama --------------------------------------------------------------- @@ -369,54 +1030,220 @@ def join(name, parts): os.remove(f'{OUT}/{p}.mp4') +@scene(media=['panorama.gif', 'panorama-aligned.png', 'panorama-filled.png'], + sources=COMMON + ['ui/dr-ui/ui/merge.slint', 'ui/dr-ui/src/merge_ui.rs', 'ui/dr-ui/src/merge.rs', + 'core/dr-pano/src/**']) def panorama(): - to_library() - library_selection() + select_pano() rec('panorama-a') - dr.click(1325, 1079); pause(40) # Merge to panorama + dr.click_on('Merge to panorama@Button') + dr.wait_for('Fill the border@RadioButton', 600) + pause(3) shot('panorama-aligned') - dr.click(184, 637); pause(6) # Fill the border + for projection in ('Perspective', 'Spherical', 'Cylindrical'): + dr.click_on(f'{projection}@RadioButton') + pause(5) + dr.click_on('Fill the border@RadioButton') + pause(6) cut() # The preview fills at the working scale — minutes on the CPU rung — # so the film pauses until the border's corner has been painted. - wait_until_lit(60, 110, 400) + x0, y0, _, _ = dr.rect('id:preview-image') + wait_until_lit(x0 + 36, y0 + 30, 400) pause(3) shot('panorama-filled') rec('panorama-b') pause(2) started = time.time() - dr.click(1543, 22); pause(8) # Merge + dr.click_on('Merge@Button') + pause(8) cut() join('panorama', ['panorama-a', 'panorama-b']) - # The merge itself takes minutes too; the last picture is the - # composite in the grid. + # The merge itself takes minutes too; the scene is done when the DNG is. wait_for_new(DEMO_LIBRARY, '.dng', started, 600) if DEMO_LIBRARY else pause(60) pause(5) - shot('panorama-done') - dr.click(*BACK); pause(2) + at_library() # --- settings --------------------------------------------------------------- +@scene(media=['settings.png', 'settings-export.png'], + sources=COMMON + ['ui/dr-ui/ui/settings.slint', 'ui/dr-ui/src/settings_ui.rs']) def settings(): - dr.click(*SETTINGS_LIB); pause(2) + at_library() + dr.click_on('Settings@Button') + dr.wait_for('BACKGROUND ACTIVITY@Text', 10) + pause(2) shot('settings') - wheel(30, 700, 600); pause(1) + x0, y0, x1, y1 = dr.rect('id:SettingsPage') + for _ in range(40): + if dr.present('Strip location and personal metadata@Checkbox', within=(x0, y0, x1, y1 - 20)): + break + wheel(3, (x0 + x1) // 2, (y0 + y1) // 2) + pause(0.3) + wheel(10, (x0 + x1) // 2, (y0 + y1) // 2) + pause(1) shot('settings-export') - dr.click(*BACK); pause(1.5) + dr.click_on('‹ Back@Button') + pause(1.5) -ALL = [ - 'library', 'library_rating', 'library_timeline', 'library_thumbsize', - 'library_selection', 'library_keywords', 'library_collections', 'library_nesting', - 'develop', 'develop_groups', 'develop_light', 'develop_zoom', 'develop_wb', - 'compose', 'local_segment', 'local_paint', 'local_done', 'repair', 'film', - 'presets', 'panorama', 'settings', -] +# --- running them ----------------------------------------------------------- +def order(names): + """`names` with their prerequisites, each once, prerequisites first.""" + seen, out = set(), [] + + def visit(n, wanted): + if n not in SCENES: + sys.exit(f'no scene {n!r}; `scenes.py list` names them') + for a in SCENES[n].after: + visit(a, False) + if n not in seen: + seen.add(n) + out.append((n, wanted)) + elif wanted: + out[[m for m, _ in out].index(n)] = (n, True) + + # The snapshot carries collections an earlier recording made; every + # scene is drawn from an empty sidebar. + if 'tidy' not in names: + visit('tidy', False) + for n in names: + visit(n, True) + return out + + +def record(out_dir, names): + """Run `names`, prerequisites first. A prerequisite nobody asked for + records into a scratch folder, so its pictures are not replaced.""" + global OUT + scratch = tempfile.mkdtemp(prefix='dr-manual-') + try: + for name, wanted in order(names): + OUT = out_dir if wanted else scratch + print(f'-- {name}' + ('' if wanted else ' (to set up the next)'), flush=True) + try: + SCENES[name].fn() + except BaseException: + stop_recording() # a failed scene must not leave ffmpeg grabbing + raise + finally: + subprocess.run(['rm', '-rf', scratch]) + + +def stop_recording(): + if os.path.exists(f'{dr.HOME}/rec.pid'): + dr.rec_stop() + + +# --- the manual and the scenes, compared ----------------------------------- +def pictures(): + """Every picture a scene makes, as the manual names it.""" + return {m: s.name for s in SCENES.values() for m in s.media if '.' in m} + + +def referenced(): + text = open(os.path.join(REPO, MANUAL)).read() + return set(re.findall(r'\]\(media/([^)\s]+)\)', text)) + + +def check(): + """The manual shows what the scenes make, all of it and nothing else.""" + made, shown = set(pictures()), referenced() + faults = [] + for m in sorted(shown - made): + faults.append(f'{MANUAL} shows media/{m}, which no scene in tools/manual/scenes.py makes') + for m in sorted(made - shown): + faults.append(f'scene {pictures()[m]} makes media/{m}, which {MANUAL} does not show') + for s in SCENES.values(): + for g in s.sources: + if not glob_matches(g): + faults.append(f'scene {s.name} depends on {g}, which matches no tracked file') + for f in faults: + print(f) + if not faults: + print(f'{len(shown)} pictures in the manual, each made by one of {len(SCENES)} scenes') + return not faults + + +def git(*args): + return subprocess.run(['git', '-C', REPO, *args], capture_output=True, text=True, + check=True).stdout + + +_tracked = None + + +def glob_matches(pattern): + global _tracked + if _tracked is None: + _tracked = git('ls-files').split('\n') + return any(fnmatch.fnmatch(f, pattern) for f in _tracked) + + +def fn_lines(s): + lines, start = inspect.getsourcelines(s.fn) + return start, start + len(lines) + + +def own_code_changed(s, since): + """Whether the lines of `s`'s function in this file differ from `since`.""" + diff = git('diff', '-U0', since, '--', os.path.relpath(__file__, REPO)) + lo, hi = fn_lines(s) + for m in re.finditer(r'^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@', diff, re.M): + start, count = int(m.group(1)), int(m.group(2) if m.group(2) is not None else 1) + if start < hi and start + max(count, 1) > lo: + return True + return False + + +def changed(): + """The scenes whose pictures are older than their sources: for each + picture, the commit that last touched it, and whether any source — or + the scene's own code — has changed since (the working tree included). + A picture never committed is new, and so is its scene.""" + out = [] + for s in SCENES.values(): + pics = [m for m in s.media if '.' in m] + if not pics: + continue + reasons = [] + for m in pics: + since = git('log', '-1', '--format=%H', '--', f'{MEDIA}/{m}').strip() + if not since: + reasons.append(f'media/{m} was never committed') + continue + paths = git('diff', '--name-only', since, '--', *[f':(glob){g}' for g in s.sources]).split() + if paths: + reasons.append(f'{", ".join(sorted(set(paths))[:3])} changed since media/{m}') + elif own_code_changed(s, since): + reasons.append(f'its scene changed since media/{m}') + if reasons: + out.append((s.name, reasons[0])) + return out + + +def main(): + global OUT + cmd, *rest = sys.argv[1:] or ['help'] + if cmd == 'record': + OUT, names = rest[0], rest[1:] + if names in ([], ['all']): + names = list(SCENES) + record(OUT, names) + elif cmd == 'order': + print(' '.join(n for n, _ in order(rest))) + elif cmd == 'check': + sys.exit(0 if check() else 1) + elif cmd == 'changed': + for name, why in changed(): + print(f'{name}\t{why}') + elif cmd == 'list': + for s in SCENES.values(): + after = f' (after {", ".join(s.after)})' if s.after else '' + print(f'{s.name}: {" ".join(m for m in s.media if "." in m) or "-"}{after}') + else: + sys.exit(__doc__) + if __name__ == '__main__': - names = sys.argv[2:] - if names == ['all']: - names = ALL - for name in names: - print(f'-- {name}', flush=True) - globals()[name]() + main() diff --git a/ui/dr-ui/src/automation.rs b/ui/dr-ui/src/automation.rs index 33b0426..4b8fbfd 100644 --- a/ui/dr-ui/src/automation.rs +++ b/ui/dr-ui/src/automation.rs @@ -24,8 +24,9 @@ //! //! - `ping` — `"ok"`. //! - `locate LABEL` — every element whose accessible label is exactly -//! `LABEL`: `[{"label","role","x","y","w","h","opacity","enabled", -//! "checked","value"}, …]`, in physical window pixels, tree order. +//! `LABEL`: `[{"label","id","type","role","x","y","w","h","opacity", +//! "enabled","checked","value","min","max"}, …]`, in physical window +//! pixels, tree order. //! - `labels [SUBSTRING]` — every labelled element, or those whose label //! contains `SUBSTRING`; the same objects. For finding what a scene can //! name. @@ -180,5 +181,7 @@ fn describe(e: &ElementHandle, scale: f32) -> Value { "enabled": e.accessible_enabled(), "checked": e.accessible_checked(), "value": e.accessible_value().map(|s| s.to_string()), + "min": e.accessible_value_minimum(), + "max": e.accessible_value_maximum(), }) } diff --git a/ui/dr-ui/ui/merge.slint b/ui/dr-ui/ui/merge.slint index ac77150..de3b463 100644 --- a/ui/dr-ui/ui/merge.slint +++ b/ui/dr-ui/ui/merge.slint @@ -176,7 +176,7 @@ export component MergePage inherits Rectangle { if root.preview.width > 0: Rectangle { height: min(root.height * 0.55, parent.width * root.preview.height / max(1, root.preview.width)); background: Theme.rule; - Image { + preview-image := Image { source: root.preview; width: 100%; height: 100%;