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.
183 lines
5.1 KiB
Python
Executable File
183 lines
5.1 KiB
Python
Executable File
#!/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__)
|