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
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:
@@ -20,3 +20,9 @@
|
|||||||
# reasoning as the models, with the opposite default: the model is not
|
# reasoning as the models, with the opposite default: the model is not
|
||||||
# optional and the fixtures are.
|
# optional and the fixtures are.
|
||||||
fixtures/** filter=lfs diff=lfs merge=lfs -text
|
fixtures/** filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|
||||||
|
# The manual's pictures live in LFS for the same reason the models do: a
|
||||||
|
# screenshot or a GIF changes wholesale when the interface it shows changes,
|
||||||
|
# and every re-recording would otherwise stay in every clone for good. CI's
|
||||||
|
# pulls exclude the directory; nothing built or tested reads it.
|
||||||
|
docs/manual/media/** filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ jobs:
|
|||||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||||
git lfs pull --exclude="fixtures/**"
|
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ jobs:
|
|||||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||||
git lfs pull --exclude="fixtures/**"
|
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
||||||
ls -lR models/
|
ls -lR models/
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
@@ -213,7 +213,7 @@ jobs:
|
|||||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||||
git lfs pull --exclude="fixtures/**"
|
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
||||||
ls -lR models/
|
ls -lR models/
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
@@ -406,7 +406,7 @@ jobs:
|
|||||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||||
git lfs pull --exclude="fixtures/**"
|
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
||||||
ls -l models/face models/scene
|
ls -l models/face models/scene
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ for the compromises that were chosen.
|
|||||||
|
|
||||||
| Document | Contents |
|
| Document | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [manual](docs/manual/README.md) | What it looks like — every feature, pictured from the application itself |
|
||||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
|
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
|
||||||
| [requirements.md](docs/requirements.md) | What the software must do — 179 numbered requirements |
|
| [requirements.md](docs/requirements.md) | What the software must do — 179 numbered requirements |
|
||||||
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
|
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# DarkRoom, shown
|
||||||
|
|
||||||
|
A tour of what the application does, one picture per thing. Every image on
|
||||||
|
this page was captured from the desktop build driving itself — nothing is a
|
||||||
|
mock-up, and nothing has been retouched outside DarkRoom. Where a feature is
|
||||||
|
better seen moving, it moves.
|
||||||
|
|
||||||
|
The requirements behind each feature are in [requirements.md](../requirements.md);
|
||||||
|
the reasoning is in the design documents linked from each section. This page
|
||||||
|
is only about what you see.
|
||||||
|
|
||||||
|
The photographs are the author's. None show a person.
|
||||||
|
|
||||||
|
## Opening a library
|
||||||
|
|
||||||
|
DarkRoom opens on a library: a folder on this machine, a folder a sync
|
||||||
|
client keeps, or a Nextcloud account. A folder needs no password and uploads
|
||||||
|
nothing.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Once a folder is named, it is the library — you are not asked for it again,
|
||||||
|
and `Open library` opens it whole. `Subfolder…` narrows the scan to part of
|
||||||
|
it. The formats ticked are what the scan looks for; RAW is on and JPEG off
|
||||||
|
by default, because a RAW editor's sensible default is the file the camera
|
||||||
|
wrote first.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## The library
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Photographs are ordered by capture time, with a month heading where each
|
||||||
|
begins. The strip on the left is the timeline — drag it to jump to a year.
|
||||||
|
The bar above the grid filters by rating, flag and where the file is (on this
|
||||||
|
device, or only on the server).
|
||||||
|
|
||||||
|
### Rating and flagging
|
||||||
|
|
||||||
|
Hover a cell and the stars appear; click one. The filter chips above the grid
|
||||||
|
count what each rating holds, and clicking `3+` shows only those.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Getting about
|
||||||
|
|
||||||
|
Drag the timeline to scrub through years; Ctrl and the wheel resize the
|
||||||
|
thumbnails.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Selecting several
|
||||||
|
|
||||||
|
`Select` in the header — or Ctrl-click — starts a selection. Shift-click picks
|
||||||
|
a range. The bar at the foot of the grid is everything a selection can be done
|
||||||
|
to: collections, keywords, presets, export, and merging to a panorama.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`Keywords` on that bar opens a sheet; type a word and press return, and it
|
||||||
|
is on every photograph selected. The list below the field is every keyword
|
||||||
|
the library has, ticked where the selection carries it.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Collections
|
||||||
|
|
||||||
|
`+` at the head of the sidebar makes one. Drag a photograph — or the whole
|
||||||
|
selection — onto its row to file it there; click the row to see it. A
|
||||||
|
photograph can be in several, and the badge on its cell counts them.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Developing a photograph
|
||||||
|
|
||||||
|
Click a thumbnail to open it. The column on the right is every adjustment;
|
||||||
|
the strip at its head narrows it to one group.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Light
|
||||||
|
|
||||||
|
Exposure, contrast, highlights, shadows, blacks, whites and a tone curve.
|
||||||
|
Hold `Before` to see the photograph as it was.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Looking closer
|
||||||
|
|
||||||
|
Double-click for 1:1; drag to move about; double-click again to fit. The
|
||||||
|
wheel zooms to any amount in between.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### White balance from the photograph
|
||||||
|
|
||||||
|
Press `pick` in the White Balance group, then click something neutral.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Composing
|
||||||
|
|
||||||
|
Crop by dragging the frame's corners, straighten with the slider, lock a
|
||||||
|
ratio from the chips. `Done composing` returns to the photograph.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Local adjustments
|
||||||
|
|
||||||
|
`Local` in the rail turns the column into a mask stack. `Find subjects` runs a
|
||||||
|
segmentation model over the photograph; what it recognises appears as a list
|
||||||
|
of categories with how much of the frame each covers. Click one and it is a
|
||||||
|
mask — then every slider below edits only that region.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A mask is a stack of parts. Paint into it, subtract a gradient from it, grow
|
||||||
|
or shrink its edge, choose how it falls off. `Show masks as` draws the mask
|
||||||
|
tinted, as alpha, or as an outline; the eye on its row switches it off.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Linear and radial gradients, a tone range and a colour range are the other
|
||||||
|
ways to make one; each can be combined with any other.
|
||||||
|
|
||||||
|
### Repair
|
||||||
|
|
||||||
|
`Repair` in the rail: click a mark and it is covered from a source DarkRoom
|
||||||
|
chooses beside it. Drag either circle to move it; the size, feather and
|
||||||
|
opacity are in the panel. Heal blends; clone copies.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Film
|
||||||
|
|
||||||
|
The `Film` chooser at the head of Adjust applies a spectral simulation of a
|
||||||
|
named stock; below it, the print exposure and push controls a film has and a
|
||||||
|
sensor does not.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### History, snapshots, presets
|
||||||
|
|
||||||
|
Every change is a step; `Undo` and the History panel walk them. `Snapshot`
|
||||||
|
keeps the current state under a name. `Presets…` saves the settings to
|
||||||
|
apply elsewhere, and imports `.xmp` from other applications.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Merging a panorama
|
||||||
|
|
||||||
|
Select the frames, then `Merge to panorama` from the selection bar. The
|
||||||
|
frames are read, aligned, and drawn on the suggested projection with each one
|
||||||
|
outlined where it landed — twelve hand-held portrait frames across an alpine
|
||||||
|
valley, here. Change the projection (a 150° sweep on a flat perspective is
|
||||||
|
what the middle of the film shows, and why cylindrical is suggested), ask for
|
||||||
|
the border to be filled rather than cropped, then `Merge`. The composite is
|
||||||
|
written beside its sources as a DNG and appears in the grid with the merge
|
||||||
|
as the first step in its history.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
`Export` in the develop header, or `Export N` from a selection. Format,
|
||||||
|
size, colour space, sharpening, naming and where the file goes are in
|
||||||
|
Settings, and apply to every export until changed. An export with no folder
|
||||||
|
set is refused, and the header says so.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Background activity with progress, thumbnail and face indexing, storage on
|
||||||
|
this device, display and colour, export defaults, what is written to XMP
|
||||||
|
sidecars, and a diagnostics bundle for a bug report.
|
||||||
|
|
||||||
|
## People
|
||||||
|
|
||||||
|
Face detection and identity run over the library and group faces by person;
|
||||||
|
the `Identity` page is where suggestions are confirmed, rejected and split,
|
||||||
|
and `People` on the filter bar narrows the grid to someone. Not pictured
|
||||||
|
here, for the obvious reason — [faces.md](../faces.md) has the design.
|
||||||
|
|
||||||
|
## Where things are written down
|
||||||
|
|
||||||
|
| Feature | Design |
|
||||||
|
|---|---|
|
||||||
|
| Local masks and segmentation | [segmentation.md](../segmentation.md), [mask-editing.md](../mask-editing.md) |
|
||||||
|
| Repair | [spot-removal.md](../spot-removal.md) |
|
||||||
|
| Panorama | [panorama.md](../panorama.md) |
|
||||||
|
| Faces and identity | [faces.md](../faces.md) |
|
||||||
|
| Gestures, generated from the code | [gestures.md](../gestures.md) |
|
||||||
|
| Navigation and layout | [ui-navigation.md](../ui-navigation.md) |
|
||||||
|
| Sync and storage | [storage.md](../storage.md) |
|
||||||
|
|
||||||
|
## How this page is made
|
||||||
|
|
||||||
|
[`tools/manual/`](../../tools/manual/README.md) drives the desktop build on
|
||||||
|
a private X server and records each scene; `record.sh <library>` re-makes
|
||||||
|
every picture here. Run it after a change to the interface and commit what
|
||||||
|
changed. The pictures are in LFS.
|
||||||
|
|
||||||
|
Making it the first time turned up nine faults, each fixed in its own
|
||||||
|
commit before the pictures were taken: the folder picker could not choose
|
||||||
|
the top level, month headings overprinted each other, a category mask
|
||||||
|
widened the column off the window, the mask tint outlived its mode, the
|
||||||
|
first sync uploaded an empty thumbnail shard, a merge that ran out of GPU
|
||||||
|
memory left the page on Stop for ever, the export settings promised to ask
|
||||||
|
for a folder and did not, the keyword sheet sent its keys to the grid, and
|
||||||
|
an empty trash told you to check your library folder.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+49
-49
File diff suppressed because one or more lines are too long
@@ -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.
|
||||||
Executable
+182
@@ -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__)
|
||||||
Executable
+7
@@ -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"
|
||||||
Executable
+61
@@ -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
|
||||||
Executable
+323
@@ -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]()
|
||||||
Reference in New Issue
Block a user