Best was a mixture of two experts and a gate, 110 GMAC a megapixel; Medium a single network at 48 that was softer on real edges. fb-combo (darkroom-denoise, 20 000 steps from fb-edges2, taught by the mixture with a quarter of its crops from the edge-rich parts of the frames) is Medium's shape and holds the mixture's edges on real photographs: edge PSNR within 0.04-0.06 dB at ISO 1600/6400/25600, more sharpness kept at all three, the chart's edge 0.89 photosites wide against 0.82. It is 0.27 dB short on smooth areas at ISO 25600. It becomes Best, and the methods are Bilinear, Fast and Best. Saved edits keep their numbers: 2, which was Medium, is now Best, and 3, which was Best, is past the end and reads as the default, Best. The network ships as mosaic-hq, a new name: the result cache keys a model by name and size, and this one is byte for byte the old Medium's size. Its tablet form (A16W16) lost 0.00 dB in simulated QDQ at every ISO and at most 0.09 dB across the noise bracket.
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', '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()
|