Every scene was recorded again on a build of this branch rebased onto the keyboard work and TD-1, since nearly every scene depends on files those changed: the develop top bar now carries a star strip and Pick/Reject, the roll shows flags and stars, and the grid's selection bar gains Label and Flag. `--changed` could not be trusted to find them, because the rebase made each picture's commit newer than the sources it was recorded from. The develop-zoom caption said the wheel goes on "until the pixels are blocks". On Xvfb the deepest frames come out smooth even though the app draws past 1:1 nearest-neighbour on a real display (confirmed by eye on the desktop), and a GIF shrunk to 960 wide could not show 3-pixel blocks anyway. The caption now says what the clip shows; the prose above it, which describes what the app does, stays.
1252 lines
38 KiB
Python
Executable File
1252 lines
38 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
|
||
|
||
Each scene drives the running app through `drive.py` and leaves the
|
||
pictures it names in OUT_DIR: PNGs, and MP4s that `record.sh` turns into
|
||
the GIFs the manual shows. A scene names its controls by the accessible
|
||
label a screen reader reads (see drive.py), so it survives a panel that
|
||
moves; what it cannot name — a point on the photograph, the crop's corner
|
||
— it places relative to something it can.
|
||
|
||
Every scene starts from a known place (`at_library`, `at_develop`) and
|
||
leaves the photograph as it found it (`undo_all`), so one can be recorded
|
||
on its own. The few that continue another's work say so with `after`, and
|
||
running one runs its prerequisites first, into a scratch folder.
|
||
|
||
`sources` are the globs of the files whose change could change the
|
||
picture; `changed` compares them, and the scene's own code here, against
|
||
the commit that last touched each of its pictures.
|
||
|
||
The library is the author's demo set (see record.sh): seventy face-free
|
||
frames, the twelve-frame alpine panorama among them. The photographs a
|
||
scene opens are named by file.
|
||
"""
|
||
import fnmatch
|
||
import inspect
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
REPO = os.path.abspath(os.path.join(HERE, '..', '..'))
|
||
MANUAL = 'docs/manual/README.md'
|
||
MEDIA = 'docs/manual/media'
|
||
|
||
sys.path.insert(0, HERE)
|
||
import drive as dr # noqa: E402
|
||
|
||
OUT = '.'
|
||
DEMO_LIBRARY = os.environ.get('DR_LIBRARY', '') # the folder the merge writes its DNG into
|
||
|
||
# The photographs the scenes open, by file.
|
||
CHINATOWN = '_MG_8393' # fire escapes, a white air conditioner, a strip of sky
|
||
TEAPOT = '_MG_4918'
|
||
PANO_FIRST, PANO_LAST = '_MG_8320', '_MG_8331'
|
||
NY_FIRST, NY_LAST = '_MG_8393', '_MG_8397'
|
||
ALPS_ROW = ['_MG_8322', '_MG_8328'] # the second row of the alps, first and last
|
||
TOWER = '_MG_8693' # towers shot looking up: verticals that converge
|
||
ROAD = '_MG_8672' # a road under a sky, with marks on it: masks and repair
|
||
ROAD_MARK = (0.305, 0.794) # a dark mark on its tarmac
|
||
|
||
# --- the registry -----------------------------------------------------------
|
||
SCENES = {}
|
||
|
||
# What almost every picture depends on: the shared controls and the palette.
|
||
COMMON = [
|
||
'ui/dr-ui/style.yaml',
|
||
'ui/dr-ui/ui/widgets.slint',
|
||
'ui/dr-ui/ui/controls.slint',
|
||
'ui/dr-ui/ui/icons.slint',
|
||
]
|
||
LIBRARY_SRC = COMMON + [
|
||
'ui/dr-ui/ui/library.slint',
|
||
'ui/dr-ui/ui/collections.slint',
|
||
'ui/dr-ui/ui/labels.slint',
|
||
'ui/dr-ui/src/library_ui/**',
|
||
'ui/dr-ui/src/library/cells.rs',
|
||
'ui/dr-ui/src/library/filters.rs',
|
||
]
|
||
DEVELOP_SRC = COMMON + [
|
||
'ui/dr-ui/ui/app.slint',
|
||
'ui/dr-ui/ui/develop.slint',
|
||
'ui/dr-ui/ui/adjust.slint',
|
||
'ui/dr-ui/ui/toolrail.slint',
|
||
'ui/dr-ui/ui/histogram.slint',
|
||
'ui/dr-ui/src/develop_ui.rs',
|
||
'ui/dr-ui/src/develop/rows.rs',
|
||
'ui/dr-ui/src/develop/tabs.rs',
|
||
]
|
||
PIPELINE_SRC = ['core/dr-pipeline/src/**', 'core/dr-gpu/src/**']
|
||
|
||
|
||
class Scene:
|
||
def __init__(self, fn, media, sources, after):
|
||
self.fn, self.name = fn, fn.__name__
|
||
self.media, self.sources, self.after = list(media), list(sources), list(after)
|
||
|
||
|
||
def scene(media, sources=(), after=()):
|
||
"""Register a scene: the pictures it leaves (as the manual names them),
|
||
the sources they depend on, and the scenes whose state it continues."""
|
||
def register(fn):
|
||
SCENES[fn.__name__] = Scene(fn, media, sources, after)
|
||
return fn
|
||
return register
|
||
|
||
|
||
# --- small verbs ------------------------------------------------------------
|
||
def shot(name):
|
||
dr.shot(f'{OUT}/{name}.png')
|
||
|
||
|
||
def rec(name):
|
||
dr.rec_start(f'{OUT}/{name}.mp4')
|
||
|
||
|
||
def cut():
|
||
dr.rec_stop()
|
||
|
||
|
||
def pause(s):
|
||
time.sleep(s)
|
||
|
||
|
||
def wheel(n, px, py):
|
||
"""`n` notches of the wheel at a point: positive scrolls down."""
|
||
dr.move(px, py)
|
||
dr.x('click', '--repeat', abs(n), '--delay', 40, 5 if n > 0 else 4)
|
||
|
||
|
||
def ctrl_wheel(n, px, py):
|
||
"""`n` notches with Ctrl held: negative (up) makes the thumbnails
|
||
larger. The pointer is nudged first — a wheel with no motion before it
|
||
can reach the grid without the hover that routes it to the zoom."""
|
||
dr.move(px + 2, py + 2)
|
||
pause(0.1)
|
||
dr.move(px, py)
|
||
dr.x('keydown', 'ctrl')
|
||
for _ in range(abs(n)):
|
||
dr.x('click', 5 if n > 0 else 4)
|
||
pause(0.35)
|
||
dr.x('keyup', 'ctrl')
|
||
|
||
|
||
def with_key(key, fn, *args):
|
||
dr.x('keydown', key)
|
||
try:
|
||
fn(*args)
|
||
finally:
|
||
dr.x('keyup', key)
|
||
|
||
|
||
def key(*keys):
|
||
dr.x('key', '--delay', 80, *keys)
|
||
|
||
|
||
def type_text(text):
|
||
dr.x('type', '--delay', 90, text)
|
||
|
||
|
||
def mid(r):
|
||
return int((r[0] + r[2]) / 2), int((r[1] + r[3]) / 2)
|
||
|
||
|
||
def enabled(name):
|
||
try:
|
||
return dr.find(name)['enabled'] is not False
|
||
except dr.NotFound:
|
||
return False
|
||
|
||
|
||
# --- where things are -------------------------------------------------------
|
||
def at_library():
|
||
"""The grid, from wherever the app is: out of develop, settings or a
|
||
merge, and out of selecting."""
|
||
for _ in range(6):
|
||
if dr.present('id:grid-scroll'):
|
||
break
|
||
for back in ('‹ Library@Button', '‹ Back@Button'):
|
||
if dr.present(back):
|
||
dr.click_on(back)
|
||
break
|
||
pause(1.5)
|
||
dr.wait_for('id:grid-scroll', 30)
|
||
if dr.present('Clear@Button'): # the photograph last opened stays selected
|
||
dr.click_on('Clear@Button')
|
||
pause(0.6)
|
||
stop_selecting()
|
||
|
||
|
||
HEADER = (0, 0, 10000, 44)
|
||
|
||
|
||
def selecting():
|
||
"""Whether the grid is selecting: `Select` in the header has become
|
||
`Done`."""
|
||
return dr.present('Done@Button', within=HEADER)
|
||
|
||
|
||
def start_selecting():
|
||
if not selecting():
|
||
dr.click_on('Select@Button', within=HEADER)
|
||
dr.wait_for('Done@Button', 5, within=HEADER)
|
||
|
||
|
||
def stop_selecting():
|
||
if selecting():
|
||
dr.click_on('Done@Button', within=HEADER)
|
||
dr.wait_for('Select@Button', 5, within=HEADER)
|
||
|
||
|
||
def grid_top():
|
||
wheel(-60, *mid(dr.rect('id:grid-scroll')))
|
||
pause(0.6)
|
||
|
||
|
||
def grid_view():
|
||
"""The part of the grid a cell can be pressed in: not under the
|
||
selection bar."""
|
||
x0, y0, x1, y1 = dr.rect('id:grid-scroll')
|
||
return x0, y0, x1, y1 - (50 if dr.present('Select all@Button') else 0)
|
||
|
||
|
||
def show_cell(name):
|
||
"""Scroll the grid until the cell for file `name` is wholly in view,
|
||
and return its rectangle."""
|
||
x0, y0, x1, y1 = grid_view()
|
||
for _ in range(60):
|
||
hits = dr.matches(f'{name}@ListItem')
|
||
if hits:
|
||
e = hits[0]
|
||
if e['y'] >= y0 and e['y'] + e['h'] <= y1:
|
||
return e['x'], e['y'], e['x'] + e['w'], e['y'] + e['h']
|
||
step = 2 if e['y'] + e['h'] > y1 else -2
|
||
else:
|
||
step = 3
|
||
wheel(step, (x0 + x1) // 2, (y0 + y1) // 2)
|
||
pause(0.4)
|
||
raise dr.NotFound(f'no cell for {name}')
|
||
|
||
|
||
def cell(name):
|
||
"""A point on `name`'s thumbnail, scrolled into view: above the middle,
|
||
clear of the star strip that a small cell draws across its centre."""
|
||
x0, y0, x1, y1 = show_cell(name)
|
||
return (x0 + x1) // 2, int(y0 + (y1 - y0) * 0.35)
|
||
|
||
|
||
def cell_width():
|
||
"""How wide the grid's cells are drawn, from whichever is on screen."""
|
||
return next(e['w'] for e in dr.ask('labels _MG_') if e['role'] == 'ListItem')
|
||
|
||
|
||
def at_develop(photo=CHINATOWN):
|
||
"""`photo` open in develop, on the Photo tool, the All group, the
|
||
column at its top, and the edit as it was opened."""
|
||
if dr.present('‹ Library@Button') and dr.present(f'{photo}.CR2'):
|
||
pass
|
||
else:
|
||
at_library()
|
||
grid_top()
|
||
dr.click(*cell(photo))
|
||
dr.wait_for(f'{photo}.CR2', 30)
|
||
wait_canvas()
|
||
for done in ('Done Composing', 'Done Masking', 'Done Repairing'):
|
||
if dr.present(f'{done}@Button'):
|
||
dr.click_on(f'{done}@Button')
|
||
pause(0.8)
|
||
tool('Photo')
|
||
undo_all()
|
||
group('All')
|
||
column_top()
|
||
|
||
|
||
def wait_canvas(timeout=30):
|
||
"""Until the canvas holds a picture."""
|
||
t0 = time.time()
|
||
while time.time() - t0 < timeout:
|
||
if dr.present('id:canvas-image') and dr.ask('canvas')['w'] > 0:
|
||
pause(1.5)
|
||
return
|
||
pause(0.5)
|
||
raise TimeoutError('the photograph never arrived')
|
||
|
||
|
||
def undo_all():
|
||
"""Walk the history back to where the photograph was opened."""
|
||
for _ in range(80):
|
||
if not enabled('Undo@Button#0'):
|
||
return
|
||
dr.click_on('Undo@Button#0')
|
||
pause(0.35)
|
||
|
||
|
||
def tool(name):
|
||
"""A tool from the rail: Photo, Compose, Local, Repair."""
|
||
dr.click_on(f'{name}@Button', within=dr.rect('id:rail'))
|
||
pause(1.2)
|
||
|
||
|
||
def group(name):
|
||
"""A group from the strip at the head of the develop column."""
|
||
dr.click_on(f'{name}@Text', within=dr.rect('id:GroupStrip'))
|
||
pause(0.9)
|
||
|
||
|
||
def column():
|
||
return dr.rect('id:develop-column')
|
||
|
||
|
||
def column_top():
|
||
wheel(-60, *mid(column()))
|
||
pause(0.6)
|
||
|
||
|
||
def in_column(name, margin=40):
|
||
"""Scroll the develop column until `name` is well inside it; its centre.
|
||
Looks where the column is first, then from the top down."""
|
||
x0, y0, x1, y1 = column()
|
||
from_top = False
|
||
for _ in range(80):
|
||
hits = dr.matches(name, within=(x0, y0, x1, y1))
|
||
if hits:
|
||
e = hits[parse_index(name)]
|
||
cy = e['y'] + e['h'] / 2
|
||
if y0 + margin <= cy <= y1 - margin:
|
||
return int(e['x'] + e['w'] / 2), int(cy)
|
||
step = 2 if cy > y1 - margin else -2
|
||
elif not from_top:
|
||
column_top()
|
||
from_top = True
|
||
continue
|
||
else:
|
||
step = 3
|
||
wheel(step, (x0 + x1) // 2, (y0 + y1) // 2)
|
||
pause(0.3)
|
||
raise dr.NotFound(f'{name} is not in the develop column')
|
||
|
||
|
||
def parse_index(name):
|
||
return dr.parse_name(name)[2]
|
||
|
||
|
||
def handle(e):
|
||
"""Where a slider's handle is: its value along the track, read from the
|
||
number its readout starts with and the range it reports. The middle
|
||
when the readout is not a number."""
|
||
m = re.match(r'\s*(-?[\d.]+)', e['value'] or '')
|
||
lo, hi = e.get('min'), e.get('max')
|
||
f = 0.5
|
||
if m and lo is not None and hi is not None and hi > lo:
|
||
f = min(max((float(m.group(1)) - lo) / (hi - lo), 0.0), 1.0)
|
||
return int(e['x'] + e['w'] * f), int(e['y'] + e['h'] / 2)
|
||
|
||
|
||
def slide(name, dx, steps=25):
|
||
"""Drag slider `name` from its handle by `dx`."""
|
||
in_column(f'{name}@Slider')
|
||
x0, y0, x1, y1 = column()
|
||
cx, cy = handle(dr.find(f'{name}@Slider', within=(x0, y0, x1, y1)))
|
||
dr.drag(cx, cy, cx + dx, cy, steps)
|
||
|
||
|
||
def hold(px, py, seconds):
|
||
dr.move(px, py)
|
||
pause(0.15)
|
||
dr.x('mousedown', 1)
|
||
pause(seconds)
|
||
dr.x('mouseup', 1)
|
||
|
||
|
||
def hold_before(seconds):
|
||
hold(*dr.centre('Before@Button'), seconds)
|
||
|
||
|
||
def photo_drag(a, b, steps=25):
|
||
dr.drag(*dr.photo(*a), *dr.photo(*b), steps)
|
||
|
||
|
||
def reset_adjust():
|
||
in_column('Reset all adjustments')
|
||
dr.click_on('Reset all adjustments')
|
||
pause(1)
|
||
|
||
|
||
# --- opening a library ------------------------------------------------------
|
||
@scene(media=['launch.png', 'launch-folder.png'],
|
||
sources=COMMON + ['ui/dr-ui/ui/launch.slint', 'ui/dr-ui/src/launch_ui.rs',
|
||
'ui/dr-ui/src/launch.rs'])
|
||
def launch():
|
||
"""The launch screen belongs to a profile with no library, so this one
|
||
is its own: the app is restarted on an empty profile beside the real
|
||
one, shown the demo folder, and put back."""
|
||
empty = f'{dr.HOME}/launch-xdg'
|
||
subprocess.run(['rm', '-rf', empty], check=True)
|
||
os.makedirs(f'{empty}/config/darkroom', exist_ok=True)
|
||
dr.stop()
|
||
dr.launch([], xdg=empty)
|
||
dr.wait_ready()
|
||
dr.wait_for('Open folder@Button', 30)
|
||
pause(1)
|
||
shot('launch')
|
||
dr.click_on('Library folder@TextInput')
|
||
type_text(DEMO_LIBRARY or '/var/tmp/dr-demo/library')
|
||
dr.click_on('Open folder@Button')
|
||
dr.wait_for('Open library@Button', 30)
|
||
pause(1)
|
||
shot('launch-folder')
|
||
dr.stop()
|
||
dr.launch([])
|
||
dr.wait_ready()
|
||
dr.wait_for('id:grid-scroll', 60)
|
||
pause(3)
|
||
|
||
|
||
# --- library ----------------------------------------------------------------
|
||
@scene(media=[], sources=[])
|
||
def tidy():
|
||
"""Not a picture: the demo library's snapshot carries the collections
|
||
an earlier recording made, and the collection scenes make them again.
|
||
Delete them through the menu, the way anyone would."""
|
||
at_library()
|
||
x0, y0, x1, y1 = dr.rect('id:CollectionsPanel')
|
||
for _ in range(12):
|
||
rows = [e for e in dr.ask('labels ')
|
||
if e['role'] == 'Text' and x0 <= e['x'] < x1 and 70 < e['y'] < y1 - 80
|
||
and e['label'] in ('Alps', 'Trips', 'New York')]
|
||
if not rows:
|
||
return
|
||
dr.click(int(rows[0]['x'] + 20), int(rows[0]['y'] + rows[0]['h'] / 2), 3)
|
||
dr.wait_for('Delete@Button', 5)
|
||
dr.click_on('Delete@Button')
|
||
pause(0.8)
|
||
if dr.present('Delete anyway@Button'):
|
||
dr.click_on('Delete anyway@Button')
|
||
pause(1.2)
|
||
|
||
|
||
@scene(media=['library.png'], sources=LIBRARY_SRC, after=['tidy'])
|
||
def library():
|
||
at_library()
|
||
grid_top()
|
||
dr.move(*mid(dr.rect('id:CollectionsPanel')))
|
||
pause(1)
|
||
shot('library')
|
||
|
||
|
||
@scene(media=['library-rating.gif'], sources=LIBRARY_SRC)
|
||
def library_rating():
|
||
"""Two photographs rated from the stars on their cells, then the grid
|
||
filtered to three stars and more, and back. Frames the demo library
|
||
leaves unrated, so a click sets a rating rather than clearing one."""
|
||
at_library()
|
||
grid_top()
|
||
show_cell('_MG_8674')
|
||
rec('library-rating')
|
||
for name, stars in (('_MG_8672', '4 stars'), ('_MG_8674', '5 stars')):
|
||
c = show_cell(name)
|
||
dr.move(*mid(c))
|
||
pause(1.0)
|
||
dr.click_on(f'{stars}@Button', within=c)
|
||
pause(1.2)
|
||
dr.move(*mid(dr.rect('id:CollectionsPanel')))
|
||
pause(0.6)
|
||
dr.click_on('3+@Button')
|
||
pause(1.5)
|
||
dr.click_on('All@Button')
|
||
pause(1.2)
|
||
cut()
|
||
|
||
|
||
@scene(media=['library-timeline.gif'], sources=LIBRARY_SRC)
|
||
def library_timeline():
|
||
at_library()
|
||
grid_top()
|
||
x0, y0, x1, y1 = dr.rect('id:Timeline')
|
||
x = (x0 + x1) // 2
|
||
rec('library-timeline')
|
||
dr.drag(x, y0 + 120, x, y1 - 120, 40)
|
||
pause(1.0)
|
||
dr.drag(x, y1 - 120, x, y0 + 70, 40)
|
||
pause(1.0)
|
||
cut()
|
||
|
||
|
||
@scene(media=['library-thumbsize.gif'], sources=LIBRARY_SRC)
|
||
def library_thumbsize():
|
||
at_library()
|
||
grid_top()
|
||
centre = mid(dr.rect('id:grid-scroll'))
|
||
before = cell_width()
|
||
rec('library-thumbsize')
|
||
ctrl_wheel(-3, *centre)
|
||
pause(0.8)
|
||
ctrl_wheel(3, *centre)
|
||
pause(0.8)
|
||
cut()
|
||
# A notch can go astray, and every scene after this one expects the
|
||
# cells the size they were.
|
||
for _ in range(12):
|
||
pause(0.8)
|
||
now = cell_width()
|
||
if now == before:
|
||
break
|
||
ctrl_wheel(-1 if now < before else 1, *centre)
|
||
grid_top()
|
||
|
||
|
||
def select_pano():
|
||
"""The twelve alpine frames: the first, shift to the last."""
|
||
at_library()
|
||
grid_top()
|
||
start_selecting()
|
||
dr.click(*cell(PANO_FIRST))
|
||
pause(0.3)
|
||
with_key('shift', dr.click, *cell(PANO_LAST))
|
||
pause(0.8)
|
||
|
||
|
||
@scene(media=['library-selection.png'], sources=LIBRARY_SRC)
|
||
def library_selection():
|
||
"""The twelve alpine frames: a range with Shift, then the last three
|
||
added one at a time with Ctrl."""
|
||
at_library()
|
||
grid_top()
|
||
start_selecting()
|
||
dr.click(*cell(PANO_FIRST))
|
||
pause(0.3)
|
||
with_key('shift', dr.click, *cell(ALPS_ROW[1]))
|
||
pause(0.8)
|
||
for name in ('_MG_8329', '_MG_8330', '_MG_8331'):
|
||
with_key('ctrl', dr.click, *cell(name))
|
||
pause(0.3)
|
||
pause(0.5)
|
||
shot('library-selection')
|
||
stop_selecting()
|
||
|
||
|
||
@scene(media=['library-keywords.gif'], sources=LIBRARY_SRC + ['ui/dr-ui/src/library_ui/ratings_keywords.rs'])
|
||
def library_keywords():
|
||
select_pano()
|
||
rec('library-keywords')
|
||
dr.click_on('Keywords@Button')
|
||
pause(1.2)
|
||
for word in ('alps', 'panorama', 'summer'):
|
||
type_text(word)
|
||
dr.x('key', 'Return')
|
||
pause(0.7)
|
||
pause(0.8)
|
||
dr.click_on('Done@Button#-1') # the sheet's, not the header's
|
||
pause(1.0)
|
||
cut()
|
||
stop_selecting()
|
||
|
||
|
||
def collection_row(name):
|
||
"""The middle of `name`'s row in the sidebar, once the tree has stopped
|
||
moving: it re-sorts after a drop or a rename a moment after the gesture,
|
||
and a row read mid-way is the neighbour's by the time it is pressed."""
|
||
x0, y0, x1, y1 = dr.rect('id:CollectionsPanel')
|
||
dr.wait_for(f'{name}@Text', 15, within=(x0, y0 + 60, x1, y1 - 80))
|
||
last = None
|
||
for _ in range(20):
|
||
now = dr.centre(f'{name}@Text', within=(x0, y0 + 60, x1, y1 - 80))
|
||
if now == last:
|
||
return now
|
||
last = now
|
||
pause(0.4)
|
||
return last
|
||
|
||
|
||
def tree_drag(what, onto):
|
||
"""Drag one sidebar row onto another, slowly enough for the tree's drag
|
||
to start under a loaded machine, and until the tree shows it nested."""
|
||
a, b = collection_row(what), collection_row(onto)
|
||
dr.x('windowfocus', '--sync', dr.win(), check=False)
|
||
dr.move(*a)
|
||
pause(0.3)
|
||
dr.x('mousedown', 1)
|
||
pause(0.4)
|
||
for i in range(1, 41):
|
||
dr.move(a[0] + (b[0] - a[0]) * i // 40, a[1] + (b[1] - a[1]) * i // 40)
|
||
pause(0.03)
|
||
pause(0.5)
|
||
dr.x('mouseup', 1)
|
||
x0, y0, x1, y1 = dr.rect('id:CollectionsPanel')
|
||
panel = (x0, y0 + 60, x1, y1 - 80)
|
||
t0 = time.time()
|
||
while time.time() - t0 < 15: # until the tree draws it inside
|
||
if dr.find(f'{what}@Text', within=panel)['x'] > dr.find(f'{onto}@Text', within=panel)['x']:
|
||
break
|
||
pause(0.4)
|
||
pause(1.0)
|
||
collection_row(what)
|
||
|
||
|
||
def open_menu(name):
|
||
"""The menu on collection `name`. A right-click that lands just after a
|
||
drag in the tree can open the menu of the row that was dragged, so the
|
||
sheet's heading is checked and the click tried again."""
|
||
for _ in range(4):
|
||
pause(0.8)
|
||
dr.click(*collection_row(name), 3)
|
||
dr.wait_for('New collection inside@Button', 5)
|
||
pause(0.6)
|
||
x0, x1 = dr.rect('New collection inside@Button')[0::2]
|
||
heads = [e for e in dr.matches(f'{name}@Text') if x0 <= e['x'] < x1]
|
||
if heads:
|
||
return
|
||
dr.click_on('Cancel@Button')
|
||
raise dr.NotFound(f'the menu on {name} would not open')
|
||
|
||
|
||
def new_collection(name):
|
||
# `+` makes a collection inside whichever row is selected, so select the
|
||
# top first for a top-level one.
|
||
dr.click_on('All photographs@Text')
|
||
pause(0.5)
|
||
dr.click_on('New collection@Button')
|
||
pause(0.4)
|
||
type_text(name)
|
||
dr.x('key', 'Return')
|
||
pause(1.2)
|
||
|
||
|
||
@scene(media=['library-collections.gif'], sources=LIBRARY_SRC + ['ui/dr-ui/src/collections_ui/**'],
|
||
after=['tidy'])
|
||
def library_collections():
|
||
at_library()
|
||
grid_top()
|
||
new_collection('Alps')
|
||
rec('library-collections')
|
||
dr.drag(*cell(PANO_FIRST), *collection_row('Alps'), 40)
|
||
pause(1.5)
|
||
start_selecting()
|
||
dr.click(*cell(ALPS_ROW[0]))
|
||
pause(0.3)
|
||
with_key('shift', dr.click, *cell(ALPS_ROW[1]))
|
||
pause(0.8)
|
||
dr.drag(*cell('_MG_8325'), *collection_row('Alps'), 40)
|
||
pause(1.5)
|
||
stop_selecting()
|
||
dr.click(*collection_row('Alps'))
|
||
pause(1.5)
|
||
cut()
|
||
dr.click_on('All photographs@Text')
|
||
pause(1.2)
|
||
|
||
|
||
@scene(media=['library-nesting.gif', 'library-nesting.png', 'library-collection-menu.png'],
|
||
sources=LIBRARY_SRC + ['ui/dr-ui/src/collections_ui/**'], after=['library_collections'])
|
||
def library_nesting():
|
||
"""Continues from library_collections: "Alps" exists with photographs
|
||
in it. Make "Trips", drag Alps into it, make "New York" inside Trips
|
||
from the menu, file frames there, then open Trips to see it count both."""
|
||
at_library()
|
||
grid_top()
|
||
new_collection('Trips')
|
||
rec('library-nesting')
|
||
pause(0.5)
|
||
tree_drag('Alps', 'Trips')
|
||
open_menu('Trips')
|
||
dr.click_on('New collection inside@Button')
|
||
pause(0.6)
|
||
type_text('New York')
|
||
dr.x('key', 'Return')
|
||
pause(1.2)
|
||
start_selecting()
|
||
dr.click(*cell(NY_FIRST))
|
||
pause(0.3)
|
||
with_key('shift', dr.click, *cell(NY_LAST))
|
||
pause(0.8)
|
||
# Out of the grid sideways first, then to the row: a diagonal with much
|
||
# vertical in it is taken by the grid's Flickable as a scroll.
|
||
start = cell('_MG_8394')
|
||
gx0 = dr.rect('id:grid-scroll')[0]
|
||
dr.drag_path([start, (gx0 - 130, start[1]), collection_row('New York')], 25)
|
||
pause(1.5)
|
||
stop_selecting()
|
||
dr.click(*collection_row('Trips'))
|
||
pause(1.5)
|
||
cut()
|
||
shot('library-nesting')
|
||
open_menu('New York')
|
||
pause(0.4)
|
||
shot('library-collection-menu')
|
||
dr.click_on('Cancel@Button')
|
||
pause(0.8)
|
||
dr.click_on('All photographs@Text')
|
||
pause(1.2)
|
||
|
||
|
||
@scene(media=['library-labels.gif'], sources=LIBRARY_SRC + ['ui/dr-ui/src/labels.rs'])
|
||
def library_labels():
|
||
"""Colour labels from the keyboard over four New York frames, then the
|
||
grid filtered to one of them, and back."""
|
||
# The grid takes the keyboard when it is built, and a sheet or a rename
|
||
# field that had it since does not give it back; opening a photograph
|
||
# and coming back builds the grid again.
|
||
at_develop(TEAPOT)
|
||
at_library()
|
||
grid_top()
|
||
frames = ['_MG_8423', '_MG_8430', '_MG_8439', '_MG_8440']
|
||
show_cell(frames[-1])
|
||
rec('library-labels')
|
||
pause(0.5)
|
||
for f, k in zip(frames, ('6', '7', '8', '9')):
|
||
dr.move(*cell(f))
|
||
pause(0.6)
|
||
key(k)
|
||
pause(0.8)
|
||
dr.move(*cell(frames[1]))
|
||
pause(0.4)
|
||
key('7') # the same key again takes it off
|
||
pause(1.0)
|
||
dr.move(*mid(dr.rect('id:CollectionsPanel')))
|
||
dr.click_on('Green@Button')
|
||
pause(1.8)
|
||
dr.click_on('Green@Button')
|
||
pause(1.2)
|
||
cut()
|
||
for f, k in zip(frames, ('6', None, '8', '9')): # and take them off again
|
||
if k:
|
||
dr.move(*cell(f))
|
||
pause(0.3)
|
||
key(k)
|
||
pause(0.4)
|
||
|
||
|
||
# --- develop ----------------------------------------------------------------
|
||
@scene(media=['develop.png'], sources=DEVELOP_SRC)
|
||
def develop():
|
||
at_develop()
|
||
dr.move(*dr.photo(0.5, 0.5))
|
||
pause(1)
|
||
shot('develop')
|
||
|
||
|
||
@scene(media=['develop-groups.gif'], sources=DEVELOP_SRC)
|
||
def develop_groups():
|
||
at_develop()
|
||
rec('develop-groups')
|
||
for g in ['Optics', 'Light', 'Colour', 'Effects', 'Detail', 'All']:
|
||
group(g)
|
||
pause(0.6)
|
||
cut()
|
||
|
||
|
||
@scene(media=['develop-light.gif'], sources=DEVELOP_SRC + PIPELINE_SRC)
|
||
def develop_light():
|
||
at_develop()
|
||
group('Light')
|
||
in_column('Shadows@Slider')
|
||
rec('develop-light')
|
||
pause(0.5)
|
||
slide('Exposure', 24)
|
||
pause(0.8)
|
||
slide('Highlights', -78)
|
||
pause(0.8)
|
||
slide('Shadows', 52)
|
||
pause(0.8)
|
||
hold_before(1.6)
|
||
pause(1.0)
|
||
cut()
|
||
undo_all()
|
||
|
||
|
||
@scene(media=['develop-zoom.gif'], sources=DEVELOP_SRC + ['ui/dr-ui/src/develop/render.rs'])
|
||
def develop_zoom():
|
||
"""1:1 with a double-click, a pan, and then 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)
|
||
|
||
|
||
@scene(media=['develop-wb.gif'], sources=DEVELOP_SRC + ['ui/dr-ui/src/develop/white_balance.rs'])
|
||
def develop_wb():
|
||
at_develop()
|
||
group('Colour')
|
||
in_column('Temperature@Slider')
|
||
rec('develop-wb')
|
||
pause(0.4)
|
||
slide('Temperature', -68)
|
||
pause(1.2)
|
||
dr.click_on('Set from a neutral in the photograph')
|
||
pause(0.8)
|
||
dr.click(*dr.photo(0.8, 0.585)) # the white air conditioner
|
||
pause(1.5)
|
||
hold_before(1.4)
|
||
pause(0.8)
|
||
cut()
|
||
undo_all()
|
||
|
||
|
||
COMPOSE_SRC = DEVELOP_SRC + ['ui/dr-ui/ui/crop.slint', 'ui/dr-ui/src/develop/framing.rs']
|
||
|
||
|
||
@scene(media=['compose.gif'], sources=COMPOSE_SRC)
|
||
def compose():
|
||
at_develop()
|
||
tool('Compose')
|
||
x0, y0, x1, y1 = dr.rect('id:move-area')
|
||
rec('compose')
|
||
pause(0.4)
|
||
dr.drag(x0 + 6, y0 + 3, x0 + 200, y0 + 151, 30)
|
||
pause(0.8)
|
||
x0, y0, x1, y1 = dr.rect('id:move-area')
|
||
dr.drag(x1 - 4, y1 - 1, x1 - 140, y1 - 65, 25)
|
||
pause(0.8)
|
||
slide('Straighten', 28, 20)
|
||
pause(1.0)
|
||
dr.click_on('1:1@RadioButton')
|
||
pause(1.2)
|
||
dr.click_on('Original@RadioButton')
|
||
pause(1.0)
|
||
dr.click_on('Done Composing@Button')
|
||
pause(1.2)
|
||
cut()
|
||
undo_all()
|
||
|
||
|
||
@scene(media=['compose-perspective.gif'], sources=COMPOSE_SRC)
|
||
def compose_perspective():
|
||
"""Towers shot looking up: their walls lean in. Vertical at about +58
|
||
stands them and the lamp posts upright, and the crop refits to what
|
||
is left."""
|
||
at_develop(TOWER)
|
||
tool('Compose')
|
||
in_column('Horizontal@Slider')
|
||
rec('compose-perspective')
|
||
pause(1.0)
|
||
slide('Vertical', 105, 30)
|
||
pause(2.0)
|
||
dr.click_on('Done Composing@Button')
|
||
pause(1.5)
|
||
hold_before(1.4)
|
||
pause(1.0)
|
||
cut()
|
||
undo_all()
|
||
|
||
|
||
@scene(media=['local-categories.png', 'local-segment.png'],
|
||
sources=DEVELOP_SRC + ['ui/dr-ui/ui/masks.slint', 'ui/dr-ui/src/masks_ui.rs',
|
||
'ui/dr-ui/src/segmentation.rs', 'ui/dr-ui/src/develop/segmentation.rs'])
|
||
def local_segment():
|
||
at_develop(ROAD)
|
||
tool('Local')
|
||
if dr.present('Find subjects@Button'): # not once the photograph has been looked at
|
||
dr.click(*in_column('Find subjects@Button'))
|
||
dr.wait_for('Sky@Text', 120)
|
||
pause(2)
|
||
shot('local-categories')
|
||
dr.click(*in_column('Sky@Text'))
|
||
pause(2.5)
|
||
shot('local-segment')
|
||
|
||
|
||
MASK_SRC = DEVELOP_SRC + ['ui/dr-ui/ui/masks.slint', 'ui/dr-ui/src/masks_ui.rs',
|
||
'ui/dr-ui/src/develop/masks.rs', 'ui/dr-ui/src/develop/mask_ops.rs']
|
||
|
||
|
||
@scene(media=['local-paint.gif'], sources=MASK_SRC, after=['local_segment'])
|
||
def local_paint():
|
||
"""Continues from local_segment: the sky mask selected and shown. The
|
||
three ways of looking at it, a stroke painted into it, then its edge
|
||
grown."""
|
||
rec('local-paint')
|
||
pause(0.5)
|
||
for how in ('Alpha', 'Edge', 'Tint'):
|
||
dr.click(*in_column(f'{how}@RadioButton'))
|
||
pause(1.4)
|
||
dr.click(*in_column('+ Add@Button'))
|
||
pause(1.0)
|
||
photo_drag((0.33, 0.28), (0.5, 0.36), 30)
|
||
pause(1.5)
|
||
dr.click(*in_column('category@Text')) # the sky's own part, whose edge it is
|
||
pause(0.8)
|
||
dr.click(*in_column('Grow@RadioButton'))
|
||
pause(2.0)
|
||
cut()
|
||
tool('Photo')
|
||
undo_all()
|
||
|
||
|
||
@scene(media=['local-intersect.gif'], sources=MASK_SRC)
|
||
def local_intersect():
|
||
"""A gradient over the lower half, then `∩ Intersect` and two strokes:
|
||
the mask keeps only where the strokes and the gradient agree."""
|
||
at_develop()
|
||
tool('Local')
|
||
rec('local-intersect')
|
||
pause(0.5)
|
||
dr.click(*in_column('Linear@Button'))
|
||
pause(1.8)
|
||
dr.click(*in_column('∩ Intersect@Button'))
|
||
pause(1.2)
|
||
photo_drag((0.6, 0.15), (0.6, 0.95), 30)
|
||
pause(0.8)
|
||
photo_drag((0.7, 0.95), (0.7, 0.15), 30)
|
||
pause(2.0)
|
||
cut()
|
||
tool('Photo')
|
||
undo_all()
|
||
|
||
|
||
@scene(media=['crop-orphan.gif'], sources=COMPOSE_SRC + MASK_SRC)
|
||
def crop_orphan():
|
||
"""A stroke in the top-left corner, then a crop that leaves it outside
|
||
the frame: the notice says so and offers the way back."""
|
||
at_develop()
|
||
tool('Local')
|
||
dr.click(*in_column('Paint@Button'))
|
||
pause(1.0)
|
||
photo_drag((0.04, 0.07), (0.16, 0.13), 20)
|
||
pause(1.0)
|
||
tool('Compose')
|
||
x0, y0, x1, y1 = dr.rect('id:move-area')
|
||
rec('crop-orphan')
|
||
pause(0.8)
|
||
dr.drag(x0 + 3, y0 + 3, *dr.photo(0.3, 0.3), 30)
|
||
dr.wait_for('Undo crop@Button', 10)
|
||
pause(2.5)
|
||
dr.click_on('Undo crop@Button')
|
||
pause(2.0)
|
||
cut()
|
||
tool('Photo')
|
||
undo_all()
|
||
|
||
|
||
@scene(media=['repair.gif'], sources=DEVELOP_SRC + ['ui/dr-ui/ui/spots.slint', 'ui/dr-ui/src/spots_ui.rs',
|
||
'ui/dr-ui/src/develop/repairs.rs'])
|
||
def repair():
|
||
at_develop(ROAD)
|
||
tool('Repair')
|
||
rec('repair')
|
||
pause(0.4)
|
||
dr.click(*dr.photo(*ROAD_MARK))
|
||
pause(3)
|
||
slide('Size', 20, 20)
|
||
pause(1.5)
|
||
hold_before(1.4)
|
||
pause(0.8)
|
||
cut()
|
||
tool('Photo')
|
||
undo_all()
|
||
|
||
|
||
@scene(media=['film.gif'], sources=DEVELOP_SRC + ['core/dr-film/src/**'])
|
||
def film():
|
||
at_develop()
|
||
rec('film')
|
||
dr.click(*in_column('Film@Text'))
|
||
pause(1.5)
|
||
dr.click(*in_column('Fujifilm Velvia 100@Text'))
|
||
pause(3)
|
||
hold_before(1.4)
|
||
pause(0.8)
|
||
cut()
|
||
undo_all()
|
||
|
||
|
||
@scene(media=['presets.png'], sources=DEVELOP_SRC + ['ui/dr-ui/ui/presets.slint', 'ui/dr-ui/src/presets.rs'])
|
||
def presets():
|
||
at_develop()
|
||
dr.click_on('Presets…@Button')
|
||
dr.wait_for('CARRIES@Text', 5)
|
||
pause(1.5)
|
||
shot('presets')
|
||
dr.click_on('Done@Button#-1')
|
||
pause(1.0)
|
||
|
||
|
||
# --- panorama ---------------------------------------------------------------
|
||
def wait_for_new(folder, suffix, since, timeout):
|
||
"""Until a file ending in `suffix` newer than `since` appears in
|
||
`folder`'s tree — the merge's DNG — or `timeout` seconds pass."""
|
||
t0 = time.time()
|
||
while time.time() - t0 < timeout:
|
||
for root, _, files in os.walk(folder):
|
||
for f in files:
|
||
if f.lower().endswith(suffix) and os.path.getmtime(os.path.join(root, f)) > since:
|
||
return True
|
||
time.sleep(2)
|
||
return False
|
||
|
||
|
||
def wait_until_lit(px, py, timeout):
|
||
"""Until the window pixel at (px, py) is no longer black — the fill
|
||
preview has reached the corner of the border — or `timeout` passes."""
|
||
t0 = time.time()
|
||
while time.time() - t0 < timeout:
|
||
out = subprocess.run(
|
||
['import', '-window', dr.win(), '-crop', f'1x1+{px}+{py}', '-depth', '8', 'txt:-'],
|
||
capture_output=True, text=True).stdout
|
||
m = re.search(r'\((\d+),(\d+),(\d+)', out)
|
||
if m and max(int(v) for v in m.groups()) > 24:
|
||
return True
|
||
time.sleep(3)
|
||
return False
|
||
|
||
|
||
def join(name, parts):
|
||
"""Concatenate recorded clips into `name`.mp4 and drop the parts."""
|
||
lst = f'{OUT}/{name}.txt'
|
||
with open(lst, 'w') as f:
|
||
for p in parts:
|
||
f.write(f"file '{OUT}/{p}.mp4'\n")
|
||
subprocess.run(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-f', 'concat', '-safe', '0',
|
||
'-i', lst, '-c', 'copy', f'{OUT}/{name}.mp4'], check=True)
|
||
os.remove(lst)
|
||
for p in parts:
|
||
os.remove(f'{OUT}/{p}.mp4')
|
||
|
||
|
||
@scene(media=['panorama.gif', 'panorama-aligned.png', 'panorama-filled.png'],
|
||
sources=COMMON + ['ui/dr-ui/ui/merge.slint', 'ui/dr-ui/src/merge_ui.rs', 'ui/dr-ui/src/merge.rs',
|
||
'core/dr-pano/src/**'])
|
||
def panorama():
|
||
select_pano()
|
||
rec('panorama-a')
|
||
dr.click_on('Merge to panorama@Button')
|
||
dr.wait_for('Fill the border@RadioButton', 600)
|
||
pause(3)
|
||
shot('panorama-aligned')
|
||
for projection in ('Perspective', 'Spherical', 'Cylindrical'):
|
||
dr.click_on(f'{projection}@RadioButton')
|
||
pause(5)
|
||
dr.click_on('Fill the border@RadioButton')
|
||
pause(6)
|
||
cut()
|
||
# The preview fills at the working scale — minutes on the CPU rung —
|
||
# so the film pauses until the border's corner has been painted.
|
||
x0, y0, _, _ = dr.rect('id:preview-image')
|
||
wait_until_lit(x0 + 36, y0 + 30, 400)
|
||
pause(3)
|
||
shot('panorama-filled')
|
||
rec('panorama-b')
|
||
pause(2)
|
||
started = time.time()
|
||
dr.click_on('Merge@Button')
|
||
pause(8)
|
||
cut()
|
||
join('panorama', ['panorama-a', 'panorama-b'])
|
||
# The merge itself takes minutes too; the scene is done when the DNG is.
|
||
wait_for_new(DEMO_LIBRARY, '.dng', started, 600) if DEMO_LIBRARY else pause(60)
|
||
pause(5)
|
||
at_library()
|
||
|
||
|
||
# --- settings ---------------------------------------------------------------
|
||
@scene(media=['settings.png', 'settings-export.png'],
|
||
sources=COMMON + ['ui/dr-ui/ui/settings.slint', 'ui/dr-ui/src/settings_ui.rs'])
|
||
def settings():
|
||
at_library()
|
||
dr.click_on('Settings@Button')
|
||
dr.wait_for('BACKGROUND ACTIVITY@Text', 10)
|
||
pause(2)
|
||
shot('settings')
|
||
x0, y0, x1, y1 = dr.rect('id:SettingsPage')
|
||
for _ in range(40):
|
||
if dr.present('Strip location and personal metadata@Checkbox', within=(x0, y0, x1, y1 - 20)):
|
||
break
|
||
wheel(3, (x0 + x1) // 2, (y0 + y1) // 2)
|
||
pause(0.3)
|
||
wheel(10, (x0 + x1) // 2, (y0 + y1) // 2)
|
||
pause(1)
|
||
shot('settings-export')
|
||
dr.click_on('‹ Back@Button')
|
||
pause(1.5)
|
||
|
||
|
||
# --- running them -----------------------------------------------------------
|
||
def order(names):
|
||
"""`names` with their prerequisites, each once, prerequisites first."""
|
||
seen, out = set(), []
|
||
|
||
def visit(n, wanted):
|
||
if n not in SCENES:
|
||
sys.exit(f'no scene {n!r}; `scenes.py list` names them')
|
||
for a in SCENES[n].after:
|
||
visit(a, False)
|
||
if n not in seen:
|
||
seen.add(n)
|
||
out.append((n, wanted))
|
||
elif wanted:
|
||
out[[m for m, _ in out].index(n)] = (n, True)
|
||
|
||
# The snapshot carries collections an earlier recording made; every
|
||
# scene is drawn from an empty sidebar.
|
||
if 'tidy' not in names:
|
||
visit('tidy', False)
|
||
for n in names:
|
||
visit(n, True)
|
||
return out
|
||
|
||
|
||
def record(out_dir, names):
|
||
"""Run `names`, prerequisites first. A prerequisite nobody asked for
|
||
records into a scratch folder, so its pictures are not replaced."""
|
||
global OUT
|
||
scratch = tempfile.mkdtemp(prefix='dr-manual-')
|
||
try:
|
||
for name, wanted in order(names):
|
||
OUT = out_dir if wanted else scratch
|
||
print(f'-- {name}' + ('' if wanted else ' (to set up the next)'), flush=True)
|
||
try:
|
||
SCENES[name].fn()
|
||
except BaseException:
|
||
stop_recording() # a failed scene must not leave ffmpeg grabbing
|
||
raise
|
||
finally:
|
||
subprocess.run(['rm', '-rf', scratch])
|
||
|
||
|
||
def stop_recording():
|
||
if os.path.exists(f'{dr.HOME}/rec.pid'):
|
||
dr.rec_stop()
|
||
|
||
|
||
# --- the manual and the scenes, compared -----------------------------------
|
||
def pictures():
|
||
"""Every picture a scene makes, as the manual names it."""
|
||
return {m: s.name for s in SCENES.values() for m in s.media if '.' in m}
|
||
|
||
|
||
def referenced():
|
||
text = open(os.path.join(REPO, MANUAL)).read()
|
||
return set(re.findall(r'\]\(media/([^)\s]+)\)', text))
|
||
|
||
|
||
def check():
|
||
"""The manual shows what the scenes make, all of it and nothing else."""
|
||
made, shown = set(pictures()), referenced()
|
||
faults = []
|
||
for m in sorted(shown - made):
|
||
faults.append(f'{MANUAL} shows media/{m}, which no scene in tools/manual/scenes.py makes')
|
||
for m in sorted(made - shown):
|
||
faults.append(f'scene {pictures()[m]} makes media/{m}, which {MANUAL} does not show')
|
||
for s in SCENES.values():
|
||
for g in s.sources:
|
||
if not glob_matches(g):
|
||
faults.append(f'scene {s.name} depends on {g}, which matches no tracked file')
|
||
for f in faults:
|
||
print(f)
|
||
if not faults:
|
||
print(f'{len(shown)} pictures in the manual, each made by one of {len(SCENES)} scenes')
|
||
return not faults
|
||
|
||
|
||
def git(*args):
|
||
return subprocess.run(['git', '-C', REPO, *args], capture_output=True, text=True,
|
||
check=True).stdout
|
||
|
||
|
||
_tracked = None
|
||
|
||
|
||
def glob_matches(pattern):
|
||
global _tracked
|
||
if _tracked is None:
|
||
_tracked = git('ls-files').split('\n')
|
||
return any(fnmatch.fnmatch(f, pattern) for f in _tracked)
|
||
|
||
|
||
def fn_lines(s):
|
||
lines, start = inspect.getsourcelines(s.fn)
|
||
return start, start + len(lines)
|
||
|
||
|
||
def own_code_changed(s, since):
|
||
"""Whether the lines of `s`'s function in this file differ from `since`."""
|
||
diff = git('diff', '-U0', since, '--', os.path.relpath(__file__, REPO))
|
||
lo, hi = fn_lines(s)
|
||
for m in re.finditer(r'^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@', diff, re.M):
|
||
start, count = int(m.group(1)), int(m.group(2) if m.group(2) is not None else 1)
|
||
if start < hi and start + max(count, 1) > lo:
|
||
return True
|
||
return False
|
||
|
||
|
||
def changed():
|
||
"""The scenes whose pictures are older than their sources: 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 == '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()
|