Files
DarkRoom/tools/manual/scenes.py
T
dtourolle c41f99ea52 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.
2026-09-25 07:26:36 -04:00

1250 lines
38 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""The manual's scenes, and what each one is for.
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
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
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 = '.'
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')
def rec(name):
dr.rec_start(f'{OUT}/{name}.mp4')
def cut():
dr.rec_stop()
def pause(s):
time.sleep(s)
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)):
dr.x('click', 5 if n > 0 else 4)
pause(0.35)
dr.x('keyup', 'ctrl')
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):
"""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 column():
return dr.rect('id:develop-column')
def column_top():
wheel(-60, *mid(column()))
pause(0.6)
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')
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(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(-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 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():
select_pano()
rec('library-keywords')
dr.click_on('Keywords@Button')
pause(1.2)
for word in ('alps', 'panorama', 'summer'):
type_text(word)
dr.x('key', 'Return')
pause(0.7)
pause(0.8)
dr.click_on('Done@Button#-1') # the sheet's, not the header's
pause(1.0)
cut()
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_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('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_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."""
at_library()
grid_top()
new_collection('Trips')
rec('library-nesting')
pause(0.5)
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')
open_menu('New York')
pause(0.4)
shot('library-collection-menu')
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():
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)
cut()
@scene(media=['develop-light.gif'], sources=DEVELOP_SRC + PIPELINE_SRC)
def develop_light():
at_develop()
group('Light')
in_column('Shadows@Slider')
rec('develop-light')
pause(0.5)
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()
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(*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():
at_develop()
group('Colour')
in_column('Temperature@Slider')
rec('develop-wb')
pause(0.4)
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()
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():
at_develop()
tool('Compose')
x0, y0, x1, y1 = dr.rect('id:move-area')
rec('compose')
pause(0.4)
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()
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():
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(*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. The
three ways of looking at it, a stroke painted into it, then its edge
grown."""
rec('local-paint')
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()
@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():
at_develop(ROAD)
tool('Repair')
rec('repair')
pause(0.4)
dr.click(*dr.photo(*ROAD_MARK))
pause(3)
slide('Size', 20, 20)
pause(1.5)
hold_before(1.4)
pause(0.8)
cut()
tool('Photo')
undo_all()
@scene(media=['film.gif'], sources=DEVELOP_SRC + ['core/dr-film/src/**'])
def film():
at_develop()
rec('film')
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()
undo_all()
@scene(media=['presets.png'], sources=DEVELOP_SRC + ['ui/dr-ui/ui/presets.slint', 'ui/dr-ui/src/presets.rs'])
def presets():
at_develop()
dr.click_on('Presets…@Button')
dr.wait_for('CARRIES@Text', 5)
pause(1.5)
shot('presets')
dr.click_on('Done@Button#-1')
pause(1.0)
# --- panorama ---------------------------------------------------------------
def wait_for_new(folder, suffix, since, timeout):
"""Until a file ending in `suffix` newer than `since` appears in
`folder`'s tree — the merge's DNG — or `timeout` seconds pass."""
t0 = time.time()
while time.time() - t0 < timeout:
for root, _, files in os.walk(folder):
for f in files:
if f.lower().endswith(suffix) and os.path.getmtime(os.path.join(root, f)) > since:
return True
time.sleep(2)
return False
def wait_until_lit(px, py, timeout):
"""Until the window pixel at (px, py) is no longer black — the fill
preview has reached the corner of the border — or `timeout` passes."""
t0 = time.time()
while time.time() - t0 < timeout:
out = subprocess.run(
['import', '-window', dr.win(), '-crop', f'1x1+{px}+{py}', '-depth', '8', 'txt:-'],
capture_output=True, text=True).stdout
m = re.search(r'\((\d+),(\d+),(\d+)', out)
if m and max(int(v) for v in m.groups()) > 24:
return True
time.sleep(3)
return False
def join(name, parts):
"""Concatenate recorded clips into `name`.mp4 and drop the parts."""
lst = f'{OUT}/{name}.txt'
with open(lst, 'w') as f:
for p in parts:
f.write(f"file '{OUT}/{p}.mp4'\n")
subprocess.run(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-f', 'concat', '-safe', '0',
'-i', lst, '-c', 'copy', f'{OUT}/{name}.mp4'], check=True)
os.remove(lst)
for p in 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():
select_pano()
rec('panorama-a')
dr.click_on('Merge to panorama@Button')
dr.wait_for('Fill the border@RadioButton', 600)
pause(3)
shot('panorama-aligned')
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.
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_on('Merge@Button')
pause(8)
cut()
join('panorama', ['panorama-a', 'panorama-b'])
# 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)
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():
at_library()
dr.click_on('Settings@Button')
dr.wait_for('BACKGROUND ACTIVITY@Text', 10)
pause(2)
shot('settings')
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_on('‹ Back@Button')
pause(1.5)
# --- 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__':
main()