The manual's AI denoise section names the four methods and their measured times, and shows the lamp and railing of the ISO 8000 frame at 1:1 by each in place of the film and the before/after pair. The scene clicks each method and waits for that network's result: the repair now logs its own "learned denoise:" line first, so the wait matches the result's.
1868 lines
62 KiB
Python
Executable File
1868 lines
62 KiB
Python
Executable File
#!/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
|
||
scenes.py gif NAME the width and frame rate NAME.gif is
|
||
made at (960 10 unless a scene says)
|
||
|
||
Each scene drives the running app through `drive.py` and leaves the
|
||
pictures it names in OUT_DIR: PNGs, and MP4s that `record.sh` turns into
|
||
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 shutil
|
||
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
|
||
ALPS_SKY = '_MG_8330' # an upright alpine frame, its top third sky and cloud
|
||
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
|
||
DENOISE = '_MG_8862' # Brooklyn Bridge at ISO 8000: a lit railing, a lamp and a camera against the night
|
||
|
||
# --- 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 that has not opened a library,
|
||
so this one is its own: the app is restarted on a profile beside the
|
||
real one that knows the demo folder, taken back to the launch screen by
|
||
`Change library`, and signed out of the folder — which leaves it on the
|
||
screen with `Open folder` beside it, as someone returning sees it. The
|
||
folder is never chosen with `Choose another…`: that is the desktop
|
||
portal's dialogue, not the app's to draw, and it does not open on a
|
||
private X server."""
|
||
other = f'{dr.HOME}/launch-xdg'
|
||
subprocess.run(['rm', '-rf', other], check=True)
|
||
os.makedirs(f'{other}/config/darkroom', exist_ok=True)
|
||
with open(f'{other}/config/darkroom/sessions.json', 'w') as f:
|
||
# The formats a first open saves — the RAWs, not JPEG — rather than
|
||
# none, which would scan (and tick) everything.
|
||
f.write('{ "version": 0, "sessions": [ { "backend": "folder", "server": "%s",'
|
||
' "login": "", "user_id": "", "root": "", "root_chosen": true,'
|
||
' "formats": ["cr2", "cr3", "nef", "arw", "raf", "rw2", "orf", "dng"],'
|
||
' "last_scan": null } ] }'
|
||
% (DEMO_LIBRARY or '/var/tmp/dr-demo/library'))
|
||
dr.stop()
|
||
dr.launch([], xdg=other)
|
||
dr.wait_ready()
|
||
dr.wait_for('id:grid-scroll', 60)
|
||
pause(3)
|
||
dr.click_on('Change library@Button')
|
||
dr.wait_for('Open library@Button', 30)
|
||
dr.move(10, 10)
|
||
pause(1)
|
||
shot('launch-folder')
|
||
dr.click_on('Sign out@Button')
|
||
dr.wait_for('Open folder@Button', 30)
|
||
dr.move(10, 10)
|
||
pause(1)
|
||
shot('launch')
|
||
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-tonemap.gif'], sources=DEVELOP_SRC + PIPELINE_SRC)
|
||
def develop_tonemap():
|
||
"""Tone Mapping on an alpine frame whose clouds are nearly white: the
|
||
white point raised to bring the clouds back from white, then lowered
|
||
for a brighter, harder picture, the contrast raised, and Before held.
|
||
Its Contrast is the Light group's second, so it is taken by position."""
|
||
at_develop(ALPS_SKY)
|
||
group('Light')
|
||
in_column('White Point@Slider')
|
||
rec('develop-tonemap')
|
||
pause(0.8)
|
||
slide('White Point', TONEMAP_WHITE_UP)
|
||
pause(1.2)
|
||
slide('White Point', -TONEMAP_WHITE_DOWN)
|
||
pause(1.2)
|
||
x0, y0, x1, y1 = column()
|
||
cx, cy = handle(dr.find('Contrast@Slider#-1', within=(x0, y0, x1, y1)))
|
||
dr.drag(cx, cy, cx + TONEMAP_CONTRAST, cy, 25)
|
||
pause(1.2)
|
||
hold_before(1.6)
|
||
pause(1.0)
|
||
cut()
|
||
undo_all()
|
||
|
||
|
||
TONEMAP_WHITE_UP, TONEMAP_WHITE_DOWN, TONEMAP_CONTRAST = 80, 110, 90 # pixels along the track
|
||
|
||
|
||
@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 further in with the wheel.
|
||
Recorded on Xvfb, the deepest frames come out smooth although the app
|
||
draws past 1:1 with nearest-neighbour on a real display; until that is
|
||
understood, this scene does not claim to show the blocks."""
|
||
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)
|
||
|
||
|
||
# The methods in the order the scene visits them: `Best` is what the
|
||
# photograph opens with, then each smaller network, then none.
|
||
DENOISE_METHODS = ['Best', 'Medium', 'Fast', 'Bilinear']
|
||
DENOISE_CLOSE_UP = 560 # pixels of canvas, square, around the lamp at 1:1
|
||
|
||
|
||
@scene(media=[f'develop-denoise-{m.lower()}.png' for m in DENOISE_METHODS],
|
||
sources=DEVELOP_SRC + ['ui/dr-ui/src/develop/denoise.rs', 'core/dr-denoise/**',
|
||
'core/dr-pipeline/src/learned_denoise.rs', 'models/denoise/**'])
|
||
def develop_denoise():
|
||
"""The lit lamp and railing of an ISO 8000 night frame at 1:1, once per
|
||
AI Denoise method, each cut to the same square of the canvas. Waits for
|
||
each network rather than for a fixed time: on the CPU, which the demo
|
||
profile uses, Best takes several times what Fast does."""
|
||
mark = log_size()
|
||
at_develop(DENOISE)
|
||
a = dr.photo(0.45, 0.55) # the lit lamp, the railing and the skyline over the water
|
||
dr.move(*a)
|
||
pause(0.3)
|
||
dr.x('click', '--repeat', 2, '--delay', 80, 1)
|
||
pause(1.5)
|
||
group('Detail')
|
||
in_column('AI Denoise@Text')
|
||
for method in DENOISE_METHODS:
|
||
if method != 'Best':
|
||
mark = log_size()
|
||
dr.click(*in_column(f'{method}@RadioButton'))
|
||
if method != 'Bilinear':
|
||
t0 = time.time()
|
||
while time.time() - t0 < 600 and not denoise_landed(mark):
|
||
pause(0.5)
|
||
pause(1.5)
|
||
close_up(f'develop-denoise-{method.lower()}', a)
|
||
undo_all()
|
||
dr.move(*a)
|
||
dr.x('click', '--repeat', 2, '--delay', 80, 1)
|
||
pause(1.2)
|
||
|
||
|
||
def close_up(name, p, size=DENOISE_CLOSE_UP):
|
||
"""A square of the canvas centred on `p`, kept inside the canvas."""
|
||
shot(name)
|
||
x0, y0, x1, y1 = dr.rect('id:canvas-image')
|
||
half = size // 2
|
||
cx = min(max(p[0], x0 + half), x1 - half)
|
||
cy = min(max(p[1], y0 + half), y1 - half)
|
||
subprocess.run(['mogrify', '-crop', f'{size}x{size}+{cx - half}+{cy - half}', '+repage',
|
||
f'{OUT}/{name}.png'], check=True)
|
||
|
||
|
||
def log_size():
|
||
try:
|
||
return os.path.getsize(f'{dr.HOME}/app.log')
|
||
except OSError:
|
||
return 0
|
||
|
||
|
||
def denoise_landed(since):
|
||
"""The app has logged the result landing since `since` bytes into its
|
||
log. The activity bar's progress has no name to wait on, and the time
|
||
it takes is the device's — seconds on a GPU, more on the CPU."""
|
||
try:
|
||
with open(f'{dr.HOME}/app.log', 'rb') as f:
|
||
f.seek(since)
|
||
# The result's line, "learned denoise: W×H on …", not the
|
||
# repair's, which comes first.
|
||
return re.search(rb'learned denoise: \d+\xc3\x97', f.read()) is not None
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
@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():
|
||
"""The list opens as a popup over the column, with its scrollbar: a turn
|
||
of the wheel down it and back, then Velvia chosen from it."""
|
||
at_develop()
|
||
in_column('Film@Button')
|
||
rec('film')
|
||
film_list_open()
|
||
pause(1.2)
|
||
px, py, pw, ph = film_list_origin()
|
||
wheel(4, px + pw // 2, py + ph // 2)
|
||
pause(1.5)
|
||
wheel(-4, px + pw // 2, py + ph // 2)
|
||
pause(1.0)
|
||
vx0, vy0, vx1, vy1 = dr.rect('Fujifilm Velvia 100@Text')
|
||
dr.click(px + (vx0 + vx1) // 2, py + (vy0 + vy1) // 2)
|
||
pause(3)
|
||
hold_before(1.4)
|
||
pause(0.8)
|
||
cut()
|
||
undo_all()
|
||
|
||
|
||
FILM_LOCAL = 'Kodak Ektar 100' # a negative, so it is printed and has a print exposure
|
||
|
||
|
||
def choose_film(stock):
|
||
"""`stock` chosen from the film list, which is open at its top."""
|
||
px, py, _, _ = film_list_origin()
|
||
vx0, vy0, vx1, vy1 = dr.rect(f'{stock}@Text')
|
||
dr.click(px + (vx0 + vx1) // 2, py + (vy0 + vy1) // 2)
|
||
pause(3)
|
||
|
||
|
||
def gradient_over_top(to_fy):
|
||
"""The linear gradient just added, turned to fall from the top of the
|
||
frame and its middle moved to `to_fy`. A new one is laid across the
|
||
middle facing right, its rotate handle below the centre: that handle,
|
||
dragged round to the right, turns it a quarter to face up."""
|
||
cx, cy = dr.photo(0.5, 0.5)
|
||
handles = [(int(h['x'] + h['w'] / 2), int(h['y'] + h['h'] / 2))
|
||
for h in dr.matches('id:GradientHandles::drag')]
|
||
rx, ry = max(handles, key=lambda p: p[1])
|
||
dr.drag(rx, ry, cx + (ry - cy), cy, 25)
|
||
pause(1.0)
|
||
dr.drag(cx, cy, *dr.photo(0.5, to_fy), 25)
|
||
pause(1.2)
|
||
|
||
|
||
@scene(media=['film-local.gif'], sources=MASK_SRC + ['core/dr-film/src/**', 'core/dr-pipeline/src/ops/film_sim.rs'])
|
||
def film_local():
|
||
"""A negative stock on the whole photograph, then a gradient over the
|
||
sky whose Print Exposure burns it in, as a printer would under the
|
||
enlarger, the mask drawn as its edge so the print shows through; Done
|
||
Masking, then Before held."""
|
||
at_develop(ALPS_SKY)
|
||
in_column('Film@Button')
|
||
film_list_open()
|
||
choose_film(FILM_LOCAL)
|
||
tool('Local')
|
||
rec('film-local')
|
||
pause(0.6)
|
||
dr.click(*in_column('Linear@Button'))
|
||
pause(1.5)
|
||
gradient_over_top(0.36)
|
||
dr.click(*in_column('Edge@RadioButton')) # an outline, so the tint hides nothing
|
||
pause(1.2)
|
||
slide('Print Exposure', 28, 30)
|
||
pause(2.0)
|
||
dr.click_on('Done Masking@Button')
|
||
pause(2.0)
|
||
hold_before(1.6)
|
||
pause(1.0)
|
||
cut()
|
||
tool('Local') # masks shown tinted again, as the other scenes
|
||
dr.click(*in_column('Tint@RadioButton')) # expect; offered while one exists
|
||
pause(0.6)
|
||
tool('Photo')
|
||
undo_all()
|
||
|
||
|
||
FILM_LAST = 'Ilford HP5 Plus' # the last stock `DevelopSession::film_choices` lists
|
||
FILM_ROWS = 28 # None and the 27 camera stocks
|
||
FILM_ROW, FILM_LIST_MAX = 32, 320
|
||
|
||
|
||
def film_list_open():
|
||
"""The film list, open, at its top: closed first if it was open. Not
|
||
by pressing the Film row again: a press outside an open list closes it
|
||
and goes no further, so that press would only close it."""
|
||
if film_list_is_open():
|
||
key('Escape')
|
||
pause(0.6)
|
||
dr.click(*in_column('Film@Button'))
|
||
dr.wait_for('Fujifilm C200@Text', 5)
|
||
pause(0.6)
|
||
|
||
|
||
def film_list_is_open():
|
||
"""Any stock's row on screen. The Film row names the chosen stock too,
|
||
so this is only true of an open list while the choice is None."""
|
||
return any(e['role'] == 'Text' for maker in ('Fujifilm', 'Kodak', 'Ilford')
|
||
for e in dr.ask(f'labels {maker}'))
|
||
|
||
|
||
def film_list_origin():
|
||
"""Where the open list's popup is in the window. The hook reports what
|
||
is inside a popup relative to the popup, so a scene adds this back. The
|
||
popup asks to sit `gap-sm` (6px) under the Film row, as wide as it, and Slint moves it
|
||
up into the window when it would run off the bottom — the same clamp."""
|
||
x0, _, x1, y1 = dr.rect('Film@Button')
|
||
W, H = dr.window_size()
|
||
h = min(FILM_ROWS * FILM_ROW, FILM_LIST_MAX)
|
||
return min(max(0, x0), W - (x1 - x0)), min(max(0, y1 + 6), H - h), x1 - x0, h
|
||
|
||
|
||
def film_reached(how):
|
||
if not dr.present(f'{FILM_LAST}@Text'):
|
||
raise AssertionError(f'the film list: {FILM_LAST} cannot be reached by {how}')
|
||
print(f' {FILM_LAST} reached by {how}', flush=True)
|
||
|
||
|
||
@scene(media=[], sources=DEVELOP_SRC + ['core/dr-film/src/**'])
|
||
def film_reach():
|
||
"""Not a picture — a check, run with every recording: the last stock in
|
||
the film list can be reached by each way there is to scroll it. It was
|
||
once a scroller inside the develop column's, and the column took the
|
||
drag and, under a still pointer, the wheel: eighteen stocks, every
|
||
black-and-white one, were on the list and out of reach."""
|
||
at_develop()
|
||
|
||
# The wheel, in one run with the pointer still: the column used to take
|
||
# a run like this whenever it had scrolled under that pointer last.
|
||
film_list_open()
|
||
px, py, pw, ph = film_list_origin()
|
||
wheel(12, px + pw // 2, py + ph // 2)
|
||
pause(0.8)
|
||
film_reached('the wheel')
|
||
|
||
# A drag on the rows, which is what a flick is to a mouse.
|
||
film_list_open()
|
||
for _ in range(3):
|
||
dr.drag(px + pw // 3, py + ph - 20, px + pw // 3, py + 10, 25)
|
||
pause(0.8)
|
||
film_reached('a drag')
|
||
|
||
# The scrollbar's thumb, dragged to the bottom of its track.
|
||
film_list_open()
|
||
dr.drag(px + pw - 5, py + 6, px + pw - 5, py + ph + 40, 25)
|
||
pause(0.6)
|
||
film_reached('the scrollbar')
|
||
|
||
# The keys, chosen with Enter: the summary names what was chosen.
|
||
film_list_open()
|
||
for _ in range(FILM_ROWS + 2):
|
||
dr.x('key', 'Down')
|
||
key('Return')
|
||
pause(1.5)
|
||
if dr.present('Fujifilm C200@Text') or not dr.present(f'{FILM_LAST}@Text'):
|
||
# Closed, and the Film row now names the last stock.
|
||
raise AssertionError(f'the film list: Down and Enter did not choose {FILM_LAST}')
|
||
print(f' {FILM_LAST} chosen by the keys', flush=True)
|
||
undo_all()
|
||
|
||
|
||
PRESET_ROW = 32 # `menu-row-height` in toolrail.slint
|
||
|
||
|
||
def presets_menu_open():
|
||
"""The presets menu at the foot of the tool rail, open."""
|
||
dr.click_on('Presets@Button')
|
||
dr.wait_for('Save or manage…@Button', 5)
|
||
|
||
|
||
def in_presets_menu(name):
|
||
"""The centre of `name` in the open presets menu, on the screen.
|
||
|
||
The automation reports a popup's elements in the popup's own
|
||
coordinates, so the menu's origin is worked out from what is known: it
|
||
opens beside the rail's entry, its foot level with the entry's, and its
|
||
last row is "Save or manage…". If the row already reads as on screen —
|
||
right of the rail — the report is taken as it stands."""
|
||
_, _, rx1, ry1 = dr.rect('Presets@Button')
|
||
mx0, _, _, my1 = dr.rect('Save or manage…@Button')
|
||
dx, dy = (0, 0) if mx0 >= rx1 else (rx1, ry1 - my1)
|
||
# "Film" is also the develop column's film chooser, so of the matches
|
||
# the one in the menu's own coordinates — left of the rail's edge — is
|
||
# the one meant.
|
||
hits = dr.matches(name)
|
||
inside = [h for h in hits if h['x'] < rx1] or hits
|
||
if not inside:
|
||
raise dr.NotFound(f'{name!r}: not in the presets menu')
|
||
h = inside[0]
|
||
x0, y0, x1, y1 = h['x'], h['y'], h['x'] + h['w'], h['y'] + h['h']
|
||
return (x0 + x1) // 2 + dx, (y0 + y1) // 2 + dy
|
||
|
||
|
||
def presets_menu_click(name):
|
||
dr.click(*in_presets_menu(name))
|
||
|
||
|
||
def presets_manage():
|
||
""""Save or manage…", the menu's last row, pressed to open the sheet."""
|
||
presets_menu_click('Save or manage…@Button')
|
||
dr.wait_for('CARRIES@Text', 5)
|
||
|
||
|
||
@scene(media=['presets-menu.png', 'presets.png'],
|
||
sources=DEVELOP_SRC + ['ui/dr-ui/ui/presets.slint', 'ui/dr-ui/src/presets.rs'])
|
||
def presets():
|
||
"""The menu with Film and its colour stocks open, then the sheet — its
|
||
folders closed again, since opening is remembered only for the menu's
|
||
session and the sheet is the same list."""
|
||
at_develop()
|
||
presets_menu_open()
|
||
pause(0.6)
|
||
presets_menu_click('Film@Button')
|
||
pause(0.6)
|
||
presets_menu_click('Colour@Button')
|
||
pause(1.0)
|
||
shot('presets-menu')
|
||
# Put back, so the sheet below opens on the folders alone.
|
||
presets_menu_click('Film@Button')
|
||
pause(0.6)
|
||
presets_manage()
|
||
pause(1.5)
|
||
shot('presets')
|
||
dr.click_on('Done@Button#-1')
|
||
pause(1.0)
|
||
|
||
|
||
@scene(media=['presets-film.gif'],
|
||
sources=DEVELOP_SRC + ['ui/dr-ui/ui/presets.slint', 'ui/dr-ui/src/presets.rs',
|
||
'core/dr-pipeline/presets/*.drpl', 'core/dr-pipeline/src/bundled.rs'])
|
||
def presets_film():
|
||
"""Film opened in the rail's menu, then Black and white inside it, and a
|
||
stock applied from there — a look, so the menu closes on the photograph
|
||
with its film changed and nothing else — then Before held."""
|
||
at_develop()
|
||
rec('presets-film')
|
||
pause(0.4)
|
||
presets_menu_open()
|
||
pause(1.0)
|
||
presets_menu_click('Film@Button')
|
||
pause(1.0)
|
||
presets_menu_click('Black and white@Button')
|
||
pause(1.2)
|
||
presets_menu_click(f'{PRESET_BW}@Button')
|
||
dr.wait_gone('Save or manage…@Button', 5)
|
||
pause(4)
|
||
hold_before(1.4)
|
||
pause(0.8)
|
||
cut()
|
||
undo_all()
|
||
|
||
|
||
PRESET_BW = 'Ilford HP5 Plus' # a shipped look in Film › Black and white
|
||
|
||
|
||
# --- 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', 'panorama-in-grid.png'],
|
||
sources=COMMON + ['ui/dr-ui/ui/merge.slint', 'ui/dr-ui/src/merge_ui.rs', 'ui/dr-ui/src/merge.rs',
|
||
'ui/dr-ui/src/merge_thumbs.rs', 'ui/dr-ui/src/library_ui/layout.rs',
|
||
'ui/dr-ui/ui/library.slint', 'core/dr-pano/src/**'])
|
||
def panorama():
|
||
select_pano()
|
||
rec('panorama-0')
|
||
dr.click_on('Merge to panorama@Button')
|
||
pause(4)
|
||
cut()
|
||
# Reading and aligning twelve frames is minutes on a busy machine; the
|
||
# film shows it start and picks up once the alignment is on the page.
|
||
dr.wait_for('Fill the border@RadioButton', 600)
|
||
pause(3)
|
||
shot('panorama-aligned')
|
||
rec('panorama-a')
|
||
pause(1)
|
||
# The first frame left out and brought back: the rest are solved again
|
||
# from the pairs already matched, so each takes a moment, not a re-read.
|
||
for _ in range(2):
|
||
dr.click(*dr.centre(f'{PANO_FIRST}.CR2@CheckBox'))
|
||
pause(4)
|
||
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-0', '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()
|
||
# The composite in the grid beside its frames, in its wide cell, with the
|
||
# thumbnail the merge made: catalogued when it was written, not when a
|
||
# scan got round to it.
|
||
grid_top()
|
||
dr.wait_for(f'{PANO_FIRST}-pano', 120)
|
||
show_cell(f'{PANO_FIRST}-pano')
|
||
pause(2)
|
||
shot('panorama-in-grid')
|
||
|
||
|
||
# --- 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)
|
||
|
||
|
||
# --- albums -----------------------------------------------------------------
|
||
ALBUM = 'Exports' # made by the app from the export folder record.sh sets
|
||
|
||
|
||
def album_row(name):
|
||
"""The sidebar row for album `name`: its label is the name and where its
|
||
files go, "Exports, On this device · …"."""
|
||
for e in dr.ask(f'labels {name}, '):
|
||
if e['role'] == 'Button' and e['label'].startswith(f'{name}, '):
|
||
return int(e['x'] + e['w'] / 2), int(e['y'] + e['h'] / 2)
|
||
raise dr.NotFound(f'no album row for {name}')
|
||
|
||
|
||
def export_button():
|
||
"""The selection bar's export button, whose label names the count and
|
||
the album: "Export 5 to Exports"."""
|
||
for e in dr.ask('labels Export '):
|
||
if e['role'] == 'Button' and e['label'].startswith('Export ') and ' to ' in e['label']:
|
||
return int(e['x'] + e['w'] / 2), int(e['y'] + e['h'] / 2)
|
||
raise dr.NotFound('no export button naming an album')
|
||
|
||
|
||
@scene(media=['albums.gif', 'albums.png', 'album-sheet.png'],
|
||
sources=LIBRARY_SRC + ['ui/dr-ui/ui/albums.slint', 'ui/dr-ui/src/albums_ui.rs',
|
||
'ui/dr-ui/ui/export.slint', 'ui/dr-ui/src/export.rs'])
|
||
def albums():
|
||
"""The New York frames exported to the album the profile's old export
|
||
folder became, then the album chosen in the sidebar: the grid shows the
|
||
originals behind its files. Then the sheet `+` opens, with a name typed
|
||
and no folder yet — its `Choose folder…` is the desktop portal's
|
||
dialogue, which does not open on a private X server. The files exported
|
||
are deleted again, and the album with them."""
|
||
folder = os.environ.get('DR_EXPORT_DIR', '')
|
||
before = set(os.listdir(folder)) if os.path.isdir(folder) else set()
|
||
try:
|
||
at_library()
|
||
grid_top()
|
||
album_row(ALBUM)
|
||
rec('albums-a')
|
||
pause(0.5)
|
||
start_selecting()
|
||
dr.click(*cell(NY_FIRST))
|
||
pause(0.3)
|
||
with_key('shift', dr.click, *cell(NY_LAST))
|
||
pause(1.0)
|
||
dr.click(*export_button())
|
||
pause(3)
|
||
cut()
|
||
try: # a short batch can be over before this looks
|
||
dr.wait_for('Cancel export@Button', 5)
|
||
except Exception:
|
||
pass
|
||
dr.wait_gone('Cancel export@Button', 300)
|
||
pause(1)
|
||
stop_selecting()
|
||
rec('albums-b')
|
||
pause(0.5)
|
||
dr.click(*album_row(ALBUM))
|
||
pause(2.5)
|
||
cut()
|
||
join('albums', ['albums-a', 'albums-b'])
|
||
dr.move(*album_row(ALBUM))
|
||
pause(1)
|
||
shot('albums')
|
||
dr.click_on('New album@Button')
|
||
dr.wait_for('Make album@Button', 5)
|
||
pause(0.6)
|
||
type_text('Prints')
|
||
pause(1)
|
||
shot('album-sheet')
|
||
dr.click_on('Cancel@Button#-1')
|
||
pause(0.8)
|
||
dr.click_on(f'Edit {ALBUM}@Button')
|
||
dr.wait_for('Delete…@Button', 5)
|
||
dr.click_on('Delete…@Button')
|
||
dr.wait_for('Delete album@Button', 5)
|
||
dr.click_on('Delete album@Button')
|
||
pause(1)
|
||
dr.click_on('All photographs@Text')
|
||
pause(1.2)
|
||
finally:
|
||
if folder and os.path.isdir(folder):
|
||
for f in set(os.listdir(folder)) - before:
|
||
os.remove(os.path.join(folder, f))
|
||
|
||
|
||
# --- duplicate originals ---------------------------------------------------
|
||
DUPLICATED = [NY_FIRST, '_MG_8394'] # copied into a `bck` folder beside their own
|
||
|
||
|
||
def library_file(name):
|
||
"""The path of file `name` in the demo library."""
|
||
for root, _, files in os.walk(DEMO_LIBRARY):
|
||
if name in files:
|
||
return os.path.join(root, name)
|
||
raise FileNotFoundError(f'{name} is not in {DEMO_LIBRARY}')
|
||
|
||
|
||
@scene(media=['duplicates.png'],
|
||
sources=LIBRARY_SRC + ['ui/dr-ui/ui/duplicates.slint', 'ui/dr-ui/src/duplicates_ui.rs',
|
||
'ui/dr-ui/src/duplicates.rs', 'core/dr-catalog/src/duplicates.rs'])
|
||
def duplicates():
|
||
"""The demo library holds no duplicates, so this makes two: copies of two
|
||
New York frames in a `bck` folder beside theirs, the way a hand-kept
|
||
backup sits. The app is restarted so its scan finds them, and the row
|
||
under the trash appears once the sweep has read their capture times. The
|
||
review is opened from there and checked, and the copies are deleted again
|
||
before the app is restarted on the library as it was. (The snapshot
|
||
record.sh restores would remove them too.)"""
|
||
made = []
|
||
try:
|
||
for name in DUPLICATED:
|
||
src = library_file(f'{name}.CR2')
|
||
folder = os.path.join(os.path.dirname(src), 'bck')
|
||
os.makedirs(folder, exist_ok=True)
|
||
made.append(os.path.join(folder, f'{name}.CR2'))
|
||
shutil.copy2(src, made[-1])
|
||
dr.stop()
|
||
dr.launch([])
|
||
dr.wait_ready()
|
||
dr.wait_for('id:grid-scroll', 60)
|
||
dr.wait_for('Duplicate originals@Text', 300)
|
||
pause(1)
|
||
dr.click_on('Duplicate originals@Text')
|
||
dr.wait_for(f'Check {len(DUPLICATED)} groups@Button', 30)
|
||
pause(1)
|
||
dr.click_on(f'Check {len(DUPLICATED)} groups@Button')
|
||
dr.wait_gone(f'Check {len(DUPLICATED)} groups@Button', 60)
|
||
pause(3)
|
||
shot('duplicates')
|
||
dr.click_on('‹ Back@Button')
|
||
pause(1)
|
||
finally:
|
||
for f in made:
|
||
if os.path.exists(f):
|
||
os.remove(f)
|
||
for f in made:
|
||
d = os.path.dirname(f)
|
||
if os.path.isdir(d) and not os.listdir(d):
|
||
os.rmdir(d)
|
||
dr.stop()
|
||
dr.launch([])
|
||
dr.wait_ready()
|
||
dr.wait_for('id:grid-scroll', 60)
|
||
pause(3)
|
||
|
||
|
||
|
||
# --- a photograph larger than one texture -------------------------------------
|
||
# Last on purpose: it adds a file to the library for its own length only, and
|
||
# no scene after it should find that file there.
|
||
GIANT_DNG = os.environ.get('DR_GIANT_DNG', '/var/tmp/dr-dng/IMG_4181-Pano.dng')
|
||
GIANT = 'IMG_4181-Pano' # a 22927 × 8966 Lightroom panorama of a glacier at dusk
|
||
|
||
# A GIF that is fine rock and ice in every frame does not compress; at the
|
||
# usual 960 px and ten frames a second this one was 22 MB, and 11 at 720.
|
||
GIF_SIZE = {'giant-pano': (640, 8)}
|
||
|
||
|
||
def restart_on_library():
|
||
dr.stop()
|
||
dr.launch([])
|
||
dr.wait_ready()
|
||
pause(5)
|
||
at_library() # it reopens on the photograph it closed on
|
||
pause(3)
|
||
|
||
|
||
def remove_giant(folder, made_folder):
|
||
for f in os.listdir(folder) if os.path.isdir(folder) else []:
|
||
if f.startswith(f'{GIANT}.'):
|
||
os.remove(os.path.join(folder, f))
|
||
if made_folder and os.path.isdir(folder) and not os.listdir(folder):
|
||
os.rmdir(folder)
|
||
|
||
|
||
@scene(media=['giant-pano.gif'],
|
||
sources=DEVELOP_SRC + ['ui/dr-ui/src/develop/session.rs', 'ui/dr-ui/src/develop/render.rs',
|
||
'core/dr-gpu/src/demosaic.rs', 'core/dr-pipeline/src/tiles.rs'])
|
||
def giant_pano():
|
||
"""A linear DNG too large for one GPU texture, opened and navigated: in
|
||
from fit on the peaks at its left as far as the wheel goes (sixteen
|
||
times fit, short of 1:1 on a frame this wide), a slow pan along the
|
||
ridge, back out, then 1:1 on the peaks with a double-click and fit with
|
||
another. The file is not in the demo library (it is 500 MB): it is
|
||
copied into a `panorama` folder for this scene, the app restarted so the
|
||
scan catalogues it, and the copy and the sidecar the app writes beside
|
||
it deleted, and the app restarted again, afterwards. DR_GIANT_DNG names it."""
|
||
folder = os.path.join(DEMO_LIBRARY, 'panorama')
|
||
copy = os.path.join(folder, f'{GIANT}.dng')
|
||
made_folder = not os.path.isdir(folder)
|
||
try:
|
||
os.makedirs(folder, exist_ok=True)
|
||
shutil.copy2(GIANT_DNG, copy)
|
||
restart_on_library()
|
||
at_library()
|
||
grid_top()
|
||
for _ in range(60): # until the scan has catalogued it
|
||
if dr.present(f'{GIANT}@ListItem'):
|
||
break
|
||
pause(2)
|
||
dr.click(*cell(GIANT))
|
||
dr.wait_for(f'{GIANT}.dng', 120)
|
||
wait_canvas(300)
|
||
pause(8) # the reduced copy, drawn
|
||
a = dr.photo(0.14, 0.22) # the snow peaks at the left
|
||
rec('giant-pano')
|
||
pause(1.5)
|
||
for _ in range(10): # the wheel's limit, 16 times fit
|
||
wheel(-1, *a)
|
||
pause(0.7)
|
||
pause(1.5)
|
||
cx, cy = mid(dr.rect('id:canvas-image'))
|
||
for _ in range(3): # along the ridge, rightwards
|
||
dr.drag(cx + 350, cy, cx - 350, cy, 90)
|
||
pause(1.0)
|
||
for _ in range(10):
|
||
wheel(1, cx, cy)
|
||
pause(0.5)
|
||
pause(1.5)
|
||
# 1:1 is past the wheel's limit on a frame this wide: a double-click
|
||
# reaches it, on the peaks the film started from, and another fits.
|
||
b = dr.photo(0.2, 0.2)
|
||
dr.move(*b)
|
||
pause(0.3)
|
||
dr.x('click', '--repeat', 2, '--delay', 80, 1)
|
||
pause(3.0)
|
||
dr.x('click', '--repeat', 2, '--delay', 80, 1)
|
||
pause(2.0)
|
||
cut()
|
||
finally:
|
||
# Back to the grid first, or the next start reopens the photograph
|
||
# it closed on and writes its sidecar again.
|
||
at_library()
|
||
dr.stop()
|
||
remove_giant(folder, made_folder)
|
||
restart_on_library()
|
||
remove_giant(folder, made_folder)
|
||
|
||
|
||
# --- running them -----------------------------------------------------------
|
||
def order(names):
|
||
"""`names` with their prerequisites, each once, prerequisites first."""
|
||
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: the commit
|
||
that last touched any of a scene's pictures, and whether a source — or
|
||
the scene's own code — has changed since (the working tree included).
|
||
A scene none of whose pictures was ever committed is new.
|
||
|
||
A re-recording that comes out byte-identical touches no picture, so its
|
||
scene stays listed until one of its pictures really changes; re-running
|
||
it is harmless."""
|
||
out = []
|
||
for s in SCENES.values():
|
||
pics = [f'{MEDIA}/{m}' for m in s.media if '.' in m]
|
||
if not pics:
|
||
continue
|
||
since = git('log', '-1', '--format=%H', '--', *pics).strip()
|
||
if not since:
|
||
out.append((s.name, 'its pictures were never committed'))
|
||
continue
|
||
paths = git('diff', '--name-only', since, '--', *[f':(glob){g}' for g in s.sources]).split()
|
||
if paths:
|
||
out.append((s.name, f'{", ".join(sorted(set(paths))[:3])} changed since {since[:9]}'))
|
||
elif own_code_changed(s, since):
|
||
out.append((s.name, f'its scene changed since {since[:9]}'))
|
||
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 == 'gif':
|
||
print(*GIF_SIZE.get(rest[0], (960, 10)))
|
||
elif cmd == 'list':
|
||
for s in SCENES.values():
|
||
after = f' (after {", ".join(s.after)})' if s.after else ''
|
||
print(f'{s.name}: {" ".join(m for m in s.media if "." in m) or "-"}{after}')
|
||
else:
|
||
sys.exit(__doc__)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|