From 757133d2a8f229f126d0bb40b2061cb5b9187cb3 Mon Sep 17 00:00:00 2001
From: Duncan Tourolle
Date: Sun, 27 Sep 2026 19:41:16 -0400
Subject: [PATCH] Picture a panorama too large for one texture being navigated
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A 22 927 × 8966 Lightroom panorama of a glacier, opened in develop:
the wheel in from fit to the peaks, a slow pan along the ridge at that
zoom, back out, then 1:1 with a double-click and fit with another. The
scene borrows the file from outside the demo library, copies it into a
panorama folder, restarts so the scan finds it, and deletes it and the
sidecar the app writes beside it before restarting again; it is
registered last so no other scene sees it.
The film is fine ice and rock in every frame and was 22 MB at the
usual 960 px and ten frames a second, so a scene can now name its own
GIF size (GIF_SIZE, read by record.sh through `scenes.py gif`); this
one is 640 px at eight, 8.7 MB.
---
docs/manual/README.md | 2 +
docs/manual/index.html | 1 +
docs/manual/media/giant-pano.gif | 3 +
tools/manual/README.md | 6 ++
tools/manual/record.sh | 3 +-
tools/manual/scenes.py | 97 ++++++++++++++++++++++++++++++++
6 files changed, 111 insertions(+), 1 deletion(-)
create mode 100644 docs/manual/media/giant-pano.gif
diff --git a/docs/manual/README.md b/docs/manual/README.md
index 1336ec6..e1117e3 100644
--- a/docs/manual/README.md
+++ b/docs/manual/README.md
@@ -359,6 +359,8 @@ It opens in develop all the same, on a reduced copy that is drawn from the
full resolution wherever you zoom in, and it exports at full size. The same
goes for a panorama Lightroom stitched and saved as a DNG.
+
+


diff --git a/docs/manual/index.html b/docs/manual/index.html
index 066050c..444af7f 100644
--- a/docs/manual/index.html
+++ b/docs/manual/index.html
@@ -400,6 +400,7 @@ preview and in the composite.
It opens in develop all the same, on a reduced copy that is drawn from the
full resolution wherever you zoom in, and it exports at full size. The same
goes for a panorama Lightroom stitched and saved as a DNG.
+A 22 927 × 8966 panorama of a glacier at dusk: zoomed from the whole frame into the peaks, panned along the ridge and back out, then 1:1 on the peaks with a double-clickTwelve frames aligned, the first left out and brought back, the projections tried, and the border filledThe alignment on a cylinder, each frame outlined where it landedThe same, with the ragged border filled by the model rather than cropped away
diff --git a/docs/manual/media/giant-pano.gif b/docs/manual/media/giant-pano.gif
new file mode 100644
index 0000000..887240c
--- /dev/null
+++ b/docs/manual/media/giant-pano.gif
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:67b186fffde402eea87a0ef9ce09320ddde9e1b5a77e2d718c4fbfb156cc82e2
+size 8704058
diff --git a/tools/manual/README.md b/tools/manual/README.md
index 8267b61..b5ab30f 100644
--- a/tools/manual/README.md
+++ b/tools/manual/README.md
@@ -47,6 +47,12 @@ 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.
+GIFs are made at 960 px and ten frames a second. A scene whose film does
+not compress at that size says otherwise in `GIF_SIZE`, which `record.sh`
+reads through `scenes.py gif NAME`. `giant_pano` borrows a 500 MB panorama
+from outside the library (`DR_GIANT_DNG`, by default
+`/var/tmp/dr-dng/IMG_4181-Pano.dng`) and removes it again, so it runs last.
+
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
than the desktop because under XWayland at scale 2 a pointer warp lands at
diff --git a/tools/manual/record.sh b/tools/manual/record.sh
index 60041d4..939cf19 100755
--- a/tools/manual/record.sh
+++ b/tools/manual/record.sh
@@ -150,7 +150,8 @@ python3 "$scenes" record "$media" "${@:-all}"
# GIFs for the page; the MP4s are working files and are not kept.
for mp4 in "$media"/*.mp4; do
[ -e "$mp4" ] || continue
- "$here/gif.sh" "$mp4" "${mp4%.mp4}.gif" 960 10
+ read -r width fps < <(python3 "$scenes" gif "$(basename "${mp4%.mp4}")")
+ "$here/gif.sh" "$mp4" "${mp4%.mp4}.gif" "$width" "$fps"
rm "$mp4"
done
find "$media" -name '*.png' -newer "$stamp" -exec optipng -quiet -o2 {} + || true
diff --git a/tools/manual/scenes.py b/tools/manual/scenes.py
index 44381cc..2b258e8 100755
--- a/tools/manual/scenes.py
+++ b/tools/manual/scenes.py
@@ -9,6 +9,8 @@
scenes.py changed the scenes whose sources changed since
their pictures were last committed
scenes.py list every scene, its pictures and sources
+ scenes.py gif NAME the width and frame rate NAME.gif is
+ made at (960 10 unless a scene says)
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
@@ -1527,6 +1529,99 @@ def duplicates():
pause(3)
+
+# --- a photograph larger than one texture -------------------------------------
+# Last on purpose: it adds a file to the library for its own length only, and
+# no scene after it should find that file there.
+GIANT_DNG = os.environ.get('DR_GIANT_DNG', '/var/tmp/dr-dng/IMG_4181-Pano.dng')
+GIANT = 'IMG_4181-Pano' # a 22927 × 8966 Lightroom panorama of a glacier at dusk
+
+# A GIF that is fine rock and ice in every frame does not compress; at the
+# usual 960 px and ten frames a second this one was 22 MB, and 11 at 720.
+GIF_SIZE = {'giant-pano': (640, 8)}
+
+
+def restart_on_library():
+ dr.stop()
+ dr.launch([])
+ dr.wait_ready()
+ pause(5)
+ at_library() # it reopens on the photograph it closed on
+ pause(3)
+
+
+def remove_giant(folder, made_folder):
+ for f in os.listdir(folder) if os.path.isdir(folder) else []:
+ if f.startswith(f'{GIANT}.'):
+ os.remove(os.path.join(folder, f))
+ if made_folder and os.path.isdir(folder) and not os.listdir(folder):
+ os.rmdir(folder)
+
+
+@scene(media=['giant-pano.gif'],
+ sources=DEVELOP_SRC + ['ui/dr-ui/src/develop/session.rs', 'ui/dr-ui/src/develop/render.rs',
+ 'core/dr-gpu/src/demosaic.rs', 'core/dr-pipeline/src/tiles.rs'])
+def giant_pano():
+ """A linear DNG too large for one GPU texture, opened and navigated: in
+ from fit on the peaks at its left as far as the wheel goes (sixteen
+ times fit, short of 1:1 on a frame this wide), a slow pan along the
+ ridge, back out, then 1:1 on the peaks with a double-click and fit with
+ another. The file is not in the demo library (it is 500 MB): it is
+ copied into a `panorama` folder for this scene, the app restarted so the
+ scan catalogues it, and the copy and the sidecar the app writes beside
+ it deleted, and the app restarted again, afterwards. DR_GIANT_DNG names it."""
+ folder = os.path.join(DEMO_LIBRARY, 'panorama')
+ copy = os.path.join(folder, f'{GIANT}.dng')
+ made_folder = not os.path.isdir(folder)
+ try:
+ os.makedirs(folder, exist_ok=True)
+ shutil.copy2(GIANT_DNG, copy)
+ restart_on_library()
+ at_library()
+ grid_top()
+ for _ in range(60): # until the scan has catalogued it
+ if dr.present(f'{GIANT}@ListItem'):
+ break
+ pause(2)
+ dr.click(*cell(GIANT))
+ dr.wait_for(f'{GIANT}.dng', 120)
+ wait_canvas(300)
+ pause(8) # the reduced copy, drawn
+ a = dr.photo(0.14, 0.22) # the snow peaks at the left
+ rec('giant-pano')
+ pause(1.5)
+ for _ in range(10): # the wheel's limit, 16 times fit
+ wheel(-1, *a)
+ pause(0.7)
+ pause(1.5)
+ cx, cy = mid(dr.rect('id:canvas-image'))
+ for _ in range(3): # along the ridge, rightwards
+ dr.drag(cx + 350, cy, cx - 350, cy, 90)
+ pause(1.0)
+ for _ in range(10):
+ wheel(1, cx, cy)
+ pause(0.5)
+ pause(1.5)
+ # 1:1 is past the wheel's limit on a frame this wide: a double-click
+ # reaches it, on the peaks the film started from, and another fits.
+ b = dr.photo(0.2, 0.2)
+ dr.move(*b)
+ pause(0.3)
+ dr.x('click', '--repeat', 2, '--delay', 80, 1)
+ pause(3.0)
+ dr.x('click', '--repeat', 2, '--delay', 80, 1)
+ pause(2.0)
+ cut()
+ finally:
+ # Back to the grid first, or the next start reopens the photograph
+ # it closed on and writes its sidecar again.
+ at_library()
+ dr.stop()
+ remove_giant(folder, made_folder)
+ restart_on_library()
+ remove_giant(folder, made_folder)
+
+
# --- running them -----------------------------------------------------------
def order(names):
"""`names` with their prerequisites, each once, prerequisites first."""
@@ -1677,6 +1772,8 @@ def main():
elif cmd == 'changed':
for name, why in changed():
print(f'{name}\t{why}')
+ elif cmd == 'gif':
+ print(*GIF_SIZE.get(rest[0], (960, 10)))
elif cmd == 'list':
for s in SCENES.values():
after = f' (after {", ".join(s.after)})' if s.after else ''