Add the manual: every feature pictured from the application itself
Benchmarks / CPU and I/O (per commit) (push) Failing after 30s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 47s
Build and test / Layer separation (push) Successful in 27s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 4s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Traceability / Requirement traces (push) Failing after 38s
Build and test / Android (aarch64) (push) Failing after 2m24s
Build and test / Windows (x86_64, cross) (push) Failing after 3m21s

docs/manual/README.md is a tour for a photographer opening DarkRoom for
the first time — one picture per thing, moving where movement is the
point. tools/manual/ is how the pictures are made: drive.py puppeteers the
desktop build on a private Xvfb (launch, click, drag, type, screenshot,
record), scenes.py is each picture as a script, and record.sh runs them
all over a folder and writes the results into docs/manual/media/.

The media is in LFS, with the CI pulls excluding it as they exclude the
fixtures; a screenshot changes wholesale when the interface does.

Nothing in the pictures shows a person, by design: the demo library is
seventy urban and alpine frames, chosen from the catalog's rows that face
detection found nobody in.

The traceability matrix is regenerated here after the rebase that
brought this branch up to master.
This commit is contained in:
2026-09-20 00:26:00 +02:00
parent 14ac41bee0
commit d790961b28
37 changed files with 957 additions and 53 deletions
+22
View File
@@ -0,0 +1,22 @@
# tools/manual — the pictures in docs/manual
`record.sh <library>` 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:
```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/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.
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
twice the coordinate asked for.
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Drive the desktop build from outside, for the manual's screenshots.
drive.py launch [args...] start the app on the private X server
drive.py stop
drive.py shot OUT.png
drive.py click X Y [button]
drive.py move X Y
drive.py drag X1 Y1 X2 Y2 [steps]
drive.py type TEXT
drive.py key KEYSYM...
drive.py rec OUT.mp4 / cut start and stop a recording of the window
drive.py where the pointer, in window coordinates
Everything is in *window* pixels, at scale 1, with the window at the origin
of a 1920×1200 Xvfb on `DR_DISPLAY` (`:7`). The app gets its own XDG
profile under `DR_HOME` (`/var/tmp/dr-manual`), so nothing here touches the
library or settings of whoever is logged in; `DR_BIN` names the binary.
Two things that cost an afternoon, kept here so they cost nobody else one:
a Slint `TouchArea` wants a *held* press (`mousedown`, a beat, `mouseup`) —
xdotool's `click` is sometimes dropped; and under XWayland at scale 2 a
pointer warp lands at twice the coordinate asked for, which is why this runs
on Xvfb rather than the desktop.
"""
import os
import subprocess
import sys
import time
HOME = os.environ.get('DR_HOME', '/var/tmp/dr-manual')
DISPLAY = os.environ.get('DR_DISPLAY', ':7')
BIN = os.environ.get('DR_BIN', 'target/release/darkroom-desktop')
ENV = dict(
os.environ,
XDG_CONFIG_HOME=f'{HOME}/xdg/config',
XDG_DATA_HOME=f'{HOME}/xdg/data',
XDG_STATE_HOME=f'{HOME}/xdg/state',
RUST_LOG='info',
WINIT_X11_SCALE_FACTOR='1',
DISPLAY=DISPLAY,
)
ENV.pop('WAYLAND_DISPLAY', None)
os.environ['DISPLAY'] = DISPLAY
def x(*args, check=True):
return subprocess.run(
['xdotool', *map(str, args)], capture_output=True, text=True, check=check
).stdout.strip()
def win():
return open(f'{HOME}/app.win').read().strip()
def geometry():
out = x('getwindowgeometry', win())
pos = out.split('Position: ')[1].split(' ')[0]
px, py = map(int, pos.split(','))
return px, py
def launch(args):
os.makedirs(HOME, exist_ok=True)
log = open(f'{HOME}/app.log', 'w')
p = subprocess.Popen([BIN, *args], env=ENV, stdout=log, stderr=subprocess.STDOUT)
open(f'{HOME}/app.pid', 'w').write(str(p.pid))
w = ''
for _ in range(120):
w = x('search', '--pid', p.pid, '--name', 'DarkRoom', check=False).split('\n')[-1]
if w:
break
time.sleep(0.5)
time.sleep(1.5)
W, H = os.environ.get('WIDTH', '1600'), os.environ.get('HEIGHT', '1100')
x('windowsize', '--sync', w, W, H)
time.sleep(0.5)
x('windowmove', '--sync', w, 0, 0)
x('windowfocus', '--sync', w, check=False)
open(f'{HOME}/app.win', 'w').write(w)
print(f'pid {p.pid} win {w}')
def stop():
try:
os.kill(int(open(f'{HOME}/app.pid').read()), 15)
except (OSError, ValueError) as e:
print(e)
time.sleep(1)
def shot(out):
subprocess.run(['import', '-window', win(), out], check=True)
def move(px, py):
ox, oy = geometry()
x('mousemove', '--sync', ox + int(px), oy + int(py))
def click(px, py, button=1):
x('windowfocus', '--sync', win(), check=False)
move(px, py)
time.sleep(0.15)
x('mousedown', button)
time.sleep(0.12)
x('mouseup', button)
def drag(x1, y1, x2, y2, steps=20):
x('windowfocus', '--sync', win(), check=False)
move(x1, y1)
time.sleep(0.15)
x('mousedown', 1)
time.sleep(0.15)
for i in range(1, int(steps) + 1):
t = i / int(steps)
move(int(x1) + (int(x2) - int(x1)) * t, int(y1) + (int(y2) - int(y1)) * t)
time.sleep(0.03)
time.sleep(0.15)
x('mouseup', 1)
def rec_start(out):
size = x('getwindowgeometry', win()).split('Geometry: ')[1].strip()
ox, oy = geometry()
p = subprocess.Popen([
'ffmpeg', '-hide_banner', '-loglevel', 'error', '-f', 'x11grab',
'-framerate', '15', '-video_size', size, '-i', f'{DISPLAY}+{ox},{oy}',
'-c:v', 'libx264', '-preset', 'ultrafast', '-qp', '0', '-y', out,
])
open(f'{HOME}/rec.pid', 'w').write(str(p.pid))
time.sleep(0.5)
def rec_stop():
pid = int(open(f'{HOME}/rec.pid').read())
os.kill(pid, 2)
for _ in range(50):
try:
os.kill(pid, 0)
time.sleep(0.1)
except OSError:
break
def where():
out = x('getmouselocation')
mx, my = [int(v.split(':')[1]) for v in out.split()[:2]]
ox, oy = geometry()
print(mx - ox, my - oy)
if __name__ == '__main__':
cmd, *a = sys.argv[1:]
if cmd == 'launch':
launch(a)
elif cmd == 'stop':
stop()
elif cmd == 'shot':
shot(a[0])
elif cmd == 'click':
click(*a)
elif cmd == 'move':
move(*a)
elif cmd == 'drag':
drag(*a)
elif cmd == 'type':
x('windowfocus', '--sync', win(), check=False)
x('type', '--delay', '120', a[0])
elif cmd == 'key':
x('windowfocus', '--sync', win(), check=False)
x('key', '--delay', '80', *a)
elif cmd == 'rec':
rec_start(a[0])
elif cmd == 'cut':
rec_stop()
elif cmd == 'where':
where()
else:
sys.exit(__doc__)
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# gif.sh in.mp4 out.gif [width] [fps] — a palette-optimised GIF of a recording.
in=$1; out=$2; w=${3:-960}; fps=${4:-10}
ffmpeg -hide_banner -loglevel error -y -i "$in" \
-vf "fps=$fps,scale=$w:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=192:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle" \
"$out"
ls -la "$out"
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Re-record the manual: docs/manual/README.md's pictures, from the app itself.
#
# tools/manual/record.sh <library-folder> [scene...]
#
# 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/.
#
# 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.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
library="${1:?library folder}"
shift || true
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}"
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)
# 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"
done
# A folder library, chosen: no launch screen on the way in.
cat > "$DR_HOME/xdg/config/darkroom/sessions.json" <<JSON
{ "version": 0, "sessions": [ { "backend": "folder", "server": "$library",
"login": "", "user_id": "", "root": "", "root_chosen": true,
"formats": [], "last_scan": null } ] }
JSON
if ! DISPLAY="$DR_DISPLAY" xdpyinfo >/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"
sleep 2
fi
python3 "$here/drive.py" launch
sleep 12
python3 "$here/scenes.py" "$media" "${@:-all}"
python3 "$here/drive.py" stop
# 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
rm "$mp4"
done
optipng -quiet -o2 "$media"/*.png || true
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env python3
"""The manual's scenes: `scenes.py OUT_DIR scene [scene...]`, or `all`.
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.
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.
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import drive as dr # noqa: E402
OUT = sys.argv[1] if len(sys.argv) > 1 else '.'
def shot(name):
dr.shot(f'{OUT}/{name}.png')
def rec(name):
dr.rec_start(f'{OUT}/{name}.mp4')
def cut():
dr.rec_stop()
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):
dr.move(px, py)
dr.x('click', '--repeat', abs(n), '--delay', 40, 5 if n > 0 else 4)
def ctrl_wheel(n, px, py):
dr.move(px, py)
dr.x('keydown', 'ctrl')
for _ in range(abs(n)):
dr.x('click', 5 if n > 0 else 4)
pause(0.35)
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 group(name):
dr.click(GROUPS[name], 67)
pause(0.9)
def to_library():
dr.click(*LIBRARY)
pause(2)
def open_chinatown():
dr.click(*CELL_CHINATOWN)
pause(6)
def reset_edit():
group('all')
wheel(-60, 1420, 700)
dr.click(*RESET_ADJUST)
pause(1)
# --- library ----------------------------------------------------------------
def library():
shot('library')
def library_rating():
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
cut()
def library_timeline():
rec('library-timeline')
dr.drag(300, 200, 300, 900, 40); pause(1.0)
dr.drag(300, 900, 300, 150, 40); pause(1.0)
cut()
def library_thumbsize():
rec('library-thumbsize')
ctrl_wheel(-4, 900, 500); pause(0.8)
ctrl_wheel(4, 900, 500); pause(0.8)
cut()
def library_selection():
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)
pause(0.5)
shot('library-selection')
def library_keywords():
# Continues from library_selection: twelve frames selected.
rec('library-keywords')
dr.click(1117, 1079); pause(1.2)
for word in ('alps', 'panorama', 'summer'):
dr.x('type', '--delay', 90, word); dr.x('key', 'Return'); pause(0.7)
pause(0.8)
dr.click(800, 673); pause(1.0) # Done
cut()
dr.click(*SELECT); pause(0.5) # leave selecting
def library_collections():
dr.click(212, 22); pause(0.3) # + in the collections header
dr.x('type', '--delay', 90, 'Alps'); dr.x('key', 'Return'); pause(1.2)
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)
cut()
shot('library-collection')
dr.click(60, 48); pause(1.2) # All photographs
# --- develop ----------------------------------------------------------------
def develop():
open_chinatown()
group('all')
shot('develop')
def develop_groups():
rec('develop-groups')
for g in ['optics', 'light', 'colour', 'effects', 'detail', 'all']:
group(g); pause(0.6)
cut()
def develop_light():
group('light')
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)
cut()
reset_edit()
def develop_zoom():
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)
cut()
def develop_wb():
group('colour')
rec('develop-wb')
pause(0.4)
dr.click(1542, 767); pause(0.8) # pick
dr.click(1000, 300); pause(1.5) # a neutral wall
hold(*BEFORE, 1.4); pause(0.8)
cut()
reset_edit()
def compose():
dr.click(*RAIL['compose']); pause(1.2)
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
cut()
shot('compose-done')
dr.click(1567, 510); pause(1.0) # reset compose
dr.click(115, 1045); pause(1.0) # refit
def local_segment():
dr.click(*RAIL['local']); pause(1.2)
dr.click(1420, 435); pause(14) # Find subjects
shot('local-categories')
dr.click(1266, 646); pause(2.5) # Sky
shot('local-segment')
def local_paint():
# Continues from local_segment: the sky mask selected and shown.
rec('local-paint')
wheel(12, 1420, 800); pause(0.8)
shot('local-mask-row')
cut()
def local_done():
dr.click(62, 1045); pause(1.0) # Done masking
reset_edit()
def repair():
dr.click(*RAIL['repair']); pause(1.2)
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)
cut()
dr.click(125, 1045); pause(1.0) # Done repairing
reset_edit()
def film():
group('all')
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)
cut()
reset_edit()
def presets():
dr.click(1420, 692); pause(1.5)
shot('presets')
dr.click(800, 783); pause(1.0) # Done
def develop_export():
dr.click(*EXPORT); pause(8)
shot('export-done')
# --- panorama ---------------------------------------------------------------
def panorama():
to_library()
library_selection()
rec('panorama')
dr.click(1325, 1079); pause(40) # Merge to panorama
dr.click(184, 637); pause(45) # Fill the border
dr.click(1543, 22); pause(60) # Merge
cut()
shot('panorama-done')
dr.click(*BACK); pause(2)
# --- settings ---------------------------------------------------------------
def settings():
dr.click(*SETTINGS_LIB); pause(2)
shot('settings')
wheel(30, 700, 600); pause(1)
shot('settings-export')
dr.click(*BACK); pause(1.5)
ALL = [
'library', 'library_rating', 'library_timeline', 'library_thumbsize',
'library_selection', 'library_keywords', 'library_collections',
'develop', 'develop_groups', 'develop_light', 'develop_zoom', 'develop_wb',
'compose', 'local_segment', 'local_paint', 'local_done', 'repair', 'film',
'presets', 'panorama', 'settings',
]
if __name__ == '__main__':
names = sys.argv[2:]
if names == ['all']:
names = ALL
for name in names:
print(f'-- {name}', flush=True)
globals()[name]()