reshoot 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reshoot/__init__.py +1 -0
- reshoot/artifacts.py +55 -0
- reshoot/browser.py +280 -0
- reshoot/bundled_skills/README.md +98 -0
- reshoot/bundled_skills/check-privacy/SKILL.md +72 -0
- reshoot/bundled_skills/cut-video/SKILL.md +77 -0
- reshoot/bundled_skills/keep-current/SKILL.md +41 -0
- reshoot/bundled_skills/plan-demo/SKILL.md +66 -0
- reshoot/bundled_skills/record-terminal/SKILL.md +50 -0
- reshoot/bundled_skills/record-terminal/template.tape +25 -0
- reshoot/bundled_skills/record-web/SKILL.md +159 -0
- reshoot/bundled_skills/record-window/SKILL.md +45 -0
- reshoot/bundled_skills/reshoot-demo/SKILL.md +28 -0
- reshoot/bundled_skills/reshoot-demo/agents/openai.yaml +4 -0
- reshoot/bundled_skills/reshoot-refresh/SKILL.md +22 -0
- reshoot/bundled_skills/reshoot-refresh/agents/openai.yaml +4 -0
- reshoot/bundled_skills/reshoot-review/SKILL.md +25 -0
- reshoot/bundled_skills/reshoot-review/agents/openai.yaml +4 -0
- reshoot/check.py +326 -0
- reshoot/cli.py +127 -0
- reshoot/config.py +83 -0
- reshoot/cut.py +371 -0
- reshoot/overlay.js +247 -0
- reshoot/project.py +92 -0
- reshoot/rec.js +88 -0
- reshoot/rec.py +181 -0
- reshoot/review.py +223 -0
- reshoot/run.py +286 -0
- reshoot/safety.py +28 -0
- reshoot/scan.py +133 -0
- reshoot/skills.py +26 -0
- reshoot/story.py +86 -0
- reshoot/templates/BRIEF.md +22 -0
- reshoot/templates/about.html +23 -0
- reshoot/templates/edit.py +9 -0
- reshoot/templates/edit.toml +13 -0
- reshoot/templates/index.html +61 -0
- reshoot/templates/reshoot.toml +15 -0
- reshoot/templates/storyboard.py +9 -0
- reshoot/templates/storyboard.toml +49 -0
- reshoot/timeline.py +244 -0
- reshoot/voice.py +70 -0
- reshoot/web.py +359 -0
- reshoot/window.py +57 -0
- reshoot/zoom.py +292 -0
- reshoot-0.3.0.dist-info/METADATA +268 -0
- reshoot-0.3.0.dist-info/RECORD +50 -0
- reshoot-0.3.0.dist-info/WHEEL +4 -0
- reshoot-0.3.0.dist-info/entry_points.txt +2 -0
- reshoot-0.3.0.dist-info/licenses/LICENSE +21 -0
reshoot/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Demo videos of software, made from scripts so you can shoot them again when the app changes."""
|
reshoot/artifacts.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Small file-based run helpers; captured JPEG bytes stay on disk."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Frames:
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.temp = tempfile.TemporaryDirectory(prefix='reshoot-capture-')
|
|
11
|
+
self.records = []
|
|
12
|
+
|
|
13
|
+
def append(self, frame):
|
|
14
|
+
at, data = frame
|
|
15
|
+
path = None
|
|
16
|
+
if data is not None:
|
|
17
|
+
path = Path(self.temp.name) / f'{len(self.records):08}.jpg'
|
|
18
|
+
path.write_bytes(data)
|
|
19
|
+
self.records.append((at, path))
|
|
20
|
+
|
|
21
|
+
def __len__(self):
|
|
22
|
+
return len(self.records)
|
|
23
|
+
|
|
24
|
+
def __getitem__(self, key):
|
|
25
|
+
if isinstance(key, slice):
|
|
26
|
+
return (self[i] for i in range(*key.indices(len(self))))
|
|
27
|
+
at, path = self.records[key]
|
|
28
|
+
return at, path.read_bytes() if path else None
|
|
29
|
+
|
|
30
|
+
def __iter__(self):
|
|
31
|
+
return (self[i] for i in range(len(self)))
|
|
32
|
+
|
|
33
|
+
def without_end(self):
|
|
34
|
+
view = object.__new__(Frames)
|
|
35
|
+
view.temp = self.temp
|
|
36
|
+
view.records = [(at, path) for at, path in self.records if path is not None]
|
|
37
|
+
return view
|
|
38
|
+
|
|
39
|
+
def __enter__(self):
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(self, *args):
|
|
43
|
+
self.temp.cleanup()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def images(frames):
|
|
47
|
+
return frames.without_end() if isinstance(frames, Frames) else [f for f in frames if f[1] is not None]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def digest(path):
|
|
51
|
+
h = hashlib.sha256()
|
|
52
|
+
with open(path, 'rb') as f:
|
|
53
|
+
for chunk in iter(lambda: f.read(1024 * 1024), b''):
|
|
54
|
+
h.update(chunk)
|
|
55
|
+
return h.hexdigest()
|
reshoot/browser.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Native-input Chromium driver for TOML storyboards. The overlay only draws."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from reshoot import story, web, zoom
|
|
9
|
+
from reshoot.artifacts import Frames, digest
|
|
10
|
+
from reshoot.safety import redact
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def locator(page, spec):
|
|
14
|
+
scope = page.frame_locator(spec['frame']) if 'frame' in spec else page
|
|
15
|
+
if 'test_id' in spec:
|
|
16
|
+
return scope.get_by_test_id(spec['test_id'])
|
|
17
|
+
if 'role' in spec:
|
|
18
|
+
return scope.get_by_role(spec['role'], **({'name': spec['name'], 'exact': True} if 'name' in spec else {}))
|
|
19
|
+
item = scope.locator(spec['css'])
|
|
20
|
+
if 'text' in spec:
|
|
21
|
+
import re
|
|
22
|
+
item = item.filter(has_text=re.compile('^' + re.escape(spec['text']) + '$'))
|
|
23
|
+
return item
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def action(page, entry, events, root):
|
|
27
|
+
kind = entry['type']
|
|
28
|
+
timeout = entry.get('timeout', 10000)
|
|
29
|
+
if kind == 'navigate':
|
|
30
|
+
await page.goto(entry['url'], timeout=timeout)
|
|
31
|
+
return
|
|
32
|
+
if kind == 'scroll':
|
|
33
|
+
await page.mouse.wheel(0, entry.get('dy', 300))
|
|
34
|
+
await page.wait_for_timeout(400)
|
|
35
|
+
return
|
|
36
|
+
item = locator(page, entry['target'])
|
|
37
|
+
if kind == 'wait':
|
|
38
|
+
await item.wait_for(state='visible', timeout=timeout)
|
|
39
|
+
return
|
|
40
|
+
if await item.count() != 1:
|
|
41
|
+
raise ValueError('action target must match exactly one element')
|
|
42
|
+
await item.scroll_into_view_if_needed(timeout=timeout)
|
|
43
|
+
box = await item.bounding_box()
|
|
44
|
+
rect = [box[k] for k in ('x', 'y', 'width', 'height')] if box else None
|
|
45
|
+
events.append((time.monotonic(), {'kind': 'act', 'phase': 'start', 'rect': rect}))
|
|
46
|
+
if box:
|
|
47
|
+
await page.evaluate('p => __demo.moveTo(p[0], p[1], 350)', [box['x'] + box['width']/2, box['y'] + box['height']/2])
|
|
48
|
+
if kind == 'click':
|
|
49
|
+
await item.click(timeout=timeout)
|
|
50
|
+
elif kind == 'type':
|
|
51
|
+
await item.click(timeout=timeout)
|
|
52
|
+
if entry.get('clear', True):
|
|
53
|
+
await item.fill('', timeout=timeout)
|
|
54
|
+
await item.press_sequentially(entry['text'], delay=65, timeout=timeout)
|
|
55
|
+
elif kind == 'key':
|
|
56
|
+
await item.press(entry['key'], timeout=timeout)
|
|
57
|
+
elif kind == 'upload':
|
|
58
|
+
await item.set_input_files(str((root / entry['file']).resolve()), timeout=timeout)
|
|
59
|
+
events.append((time.monotonic(), {'kind': 'act', 'phase': 'end', 'rect': rect}))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def assertion(page, entry):
|
|
63
|
+
from playwright.async_api import expect
|
|
64
|
+
kind = entry['type']
|
|
65
|
+
timeout = entry.get('timeout', 10000)
|
|
66
|
+
if kind == 'url':
|
|
67
|
+
import re
|
|
68
|
+
await expect(page).to_have_url(re.compile(re.escape(entry['contains'])), timeout=timeout)
|
|
69
|
+
return page.url
|
|
70
|
+
item = locator(page, entry['target'])
|
|
71
|
+
if kind == 'count':
|
|
72
|
+
await expect(item).to_have_count(entry['count'], timeout=timeout)
|
|
73
|
+
return await item.count()
|
|
74
|
+
await expect(item).to_be_visible(timeout=timeout)
|
|
75
|
+
if kind == 'text':
|
|
76
|
+
await expect(item).to_contain_text(entry['contains'], timeout=timeout)
|
|
77
|
+
return await item.inner_text()
|
|
78
|
+
if kind == 'value':
|
|
79
|
+
await expect(item).to_have_value(entry['value'], timeout=timeout)
|
|
80
|
+
return await item.input_value()
|
|
81
|
+
if kind == 'checked':
|
|
82
|
+
await expect(item).to_be_checked(checked=entry.get('checked', True), timeout=timeout)
|
|
83
|
+
return await item.is_checked()
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def capture(board, a, frames, out, metadata, spoken):
|
|
88
|
+
from playwright.async_api import async_playwright
|
|
89
|
+
w, h, dpr = web.parse_size(a.size or board.get('size', '1280x720@1'))
|
|
90
|
+
if os.environ.get('RESHOOT_PREVIEW') == '1':
|
|
91
|
+
dpr = min(dpr, 720 / max(w, h))
|
|
92
|
+
z = zoom.settings(a.zoom if a.zoom is not None else board.get('zoom'))
|
|
93
|
+
if board.get('caption'):
|
|
94
|
+
z = z or {**zoom.DEFAULTS, 'scale': 1, 'watch': False}
|
|
95
|
+
style = dict(board['caption'])
|
|
96
|
+
if style.get('font'):
|
|
97
|
+
style['font'] = str(Path(a.storyboard).resolve().parent / style['font'])
|
|
98
|
+
z['caption_style'] = style
|
|
99
|
+
cap_dpr = min(dpr * min(z['scale'], 2), 4) if z else dpr
|
|
100
|
+
first = getattr(a, 'from_beat', None) or os.environ.get('RESHOOT_FROM_BEAT')
|
|
101
|
+
ids = [b['id'] for b in board['beat']]
|
|
102
|
+
metadata['mode'] = 'partial' if first else 'preview' if os.environ.get('RESHOOT_PREVIEW') else 'final'
|
|
103
|
+
if first and first not in ids:
|
|
104
|
+
raise ValueError('unknown starting beat')
|
|
105
|
+
split = ids.index(first) if first else 0
|
|
106
|
+
events, beats, pending, assets, resources = [], {}, [], [], []
|
|
107
|
+
clock = None
|
|
108
|
+
root = Path(a.storyboard).resolve().parent
|
|
109
|
+
async with async_playwright() as pw:
|
|
110
|
+
browser = await pw.chromium.launch(executable_path=web.find_chrome(), headless=True, args=web.chrome_flags())
|
|
111
|
+
context = await browser.new_context(viewport={'width': w, 'height': h}, device_scale_factor=cap_dpr,
|
|
112
|
+
color_scheme=a.theme or board.get('theme', 'dark'), locale='en-US', timezone_id='UTC',
|
|
113
|
+
is_mobile=bool(a.mobile or board.get('mobile')), has_touch=bool(a.mobile or board.get('mobile')))
|
|
114
|
+
page = await context.new_page()
|
|
115
|
+
metadata['browser'] = browser.version
|
|
116
|
+
errors = []
|
|
117
|
+
page.on('pageerror', lambda e: errors.append(redact(str(e))))
|
|
118
|
+
await page.expose_binding('__reshoot', lambda source, payload: events.append((time.monotonic(), json.loads(payload))))
|
|
119
|
+
await page.add_init_script('window.__reshootPostCaptions = ' + json.dumps(bool(z)) + '; window.__reshootBlur = ' + json.dumps(board.get('blur', [])) + '; window.__reshootSeed = ' + str(board.get('seed', 1)) + ';')
|
|
120
|
+
await page.add_init_script((web.HERE / 'overlay.js').read_text())
|
|
121
|
+
asset_folder = out.with_suffix('.resources')
|
|
122
|
+
asset_folder.mkdir(exist_ok=True)
|
|
123
|
+
async def save_resource(response):
|
|
124
|
+
if response.request.resource_type not in ('image', 'media'):
|
|
125
|
+
return
|
|
126
|
+
from reshoot.safety import describe
|
|
127
|
+
record = {'source': describe(response.url), 'status': 'unavailable'}
|
|
128
|
+
try:
|
|
129
|
+
data = await response.body()
|
|
130
|
+
import hashlib
|
|
131
|
+
name = hashlib.sha256(data).hexdigest()
|
|
132
|
+
target = asset_folder / name
|
|
133
|
+
target.write_bytes(data)
|
|
134
|
+
record.update(status='saved', file=str(target.relative_to(out.parent)), sha256=name, content_type=response.headers.get('content-type', ''))
|
|
135
|
+
if response.request.resource_type == 'image':
|
|
136
|
+
from reshoot.check import open_image
|
|
137
|
+
if open_image(data) is not None:
|
|
138
|
+
assets.append(str(target))
|
|
139
|
+
else:
|
|
140
|
+
record['status'] = 'unsupported'
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
resources.append(record)
|
|
144
|
+
resource_handler = lambda response: pending.append(asyncio.create_task(save_resource(response)))
|
|
145
|
+
page.on('response', resource_handler)
|
|
146
|
+
cdp = await context.new_cdp_session(page)
|
|
147
|
+
# Playwright's virtual viewport can exceed Chrome's visible capture surface.
|
|
148
|
+
# CDP screencasting needs both sizes, rather than stretching a clipped frame.
|
|
149
|
+
await cdp.send('Emulation.setDeviceMetricsOverride', {'width': w, 'height': h, 'deviceScaleFactor': cap_dpr,
|
|
150
|
+
'mobile': bool(a.mobile or board.get('mobile')), 'screenWidth': w, 'screenHeight': h,
|
|
151
|
+
'dontSetVisibleSize': False})
|
|
152
|
+
async def frame(event):
|
|
153
|
+
nonlocal clock
|
|
154
|
+
timestamp = event['metadata']['timestamp']
|
|
155
|
+
if clock is None:
|
|
156
|
+
clock = time.monotonic() - timestamp
|
|
157
|
+
frames.append((timestamp + clock, base64.b64decode(event['data'])))
|
|
158
|
+
await cdp.send('Page.screencastFrameAck', {'sessionId': event['sessionId']})
|
|
159
|
+
frame_tasks = []
|
|
160
|
+
frame_handler = lambda event: frame_tasks.append(asyncio.create_task(frame(event)))
|
|
161
|
+
cdp.on('Page.screencastFrame', frame_handler)
|
|
162
|
+
current = None
|
|
163
|
+
try:
|
|
164
|
+
await page.goto(a.url or board.get('url', 'http://localhost:8000/'), wait_until='load', timeout=30000)
|
|
165
|
+
await page.evaluate('document.fonts.ready')
|
|
166
|
+
for entry in board.get('setup', []):
|
|
167
|
+
await action(page, entry, events, root)
|
|
168
|
+
for previous in board['beat'][:split]:
|
|
169
|
+
for entry in previous.get('action', []):
|
|
170
|
+
await action(page, entry, events, root)
|
|
171
|
+
for entry in previous.get('expect', []):
|
|
172
|
+
await assertion(page, entry)
|
|
173
|
+
metadata['replayed_before'] = ids[:split]
|
|
174
|
+
events.clear()
|
|
175
|
+
await cdp.send('Page.startScreencast', {'format': 'jpeg', 'quality': 92, 'maxWidth': int(w*cap_dpr), 'maxHeight': int(h*cap_dpr)})
|
|
176
|
+
for beat in board['beat'][split:]:
|
|
177
|
+
current = beat['id']
|
|
178
|
+
start = beats[current] = time.monotonic()
|
|
179
|
+
result = {'id': current, 'purpose': beat.get('purpose'), 'caption': beat.get('caption'),
|
|
180
|
+
'definition_sha256': __import__('hashlib').sha256(json.dumps(beat, sort_keys=True).encode()).hexdigest(),
|
|
181
|
+
'status': 'running', 'expectations': []}
|
|
182
|
+
metadata['beats'].append(result)
|
|
183
|
+
await page.evaluate('p => __demo.caption(p[0], p[1])', [beat.get('caption'), beat.get('sub')])
|
|
184
|
+
for entry in beat.get('action', []):
|
|
185
|
+
await action(page, entry, events, root)
|
|
186
|
+
for entry in beat.get('expect', []):
|
|
187
|
+
try:
|
|
188
|
+
actual = await assertion(page, entry)
|
|
189
|
+
except Exception as error:
|
|
190
|
+
result['expectations'].append({'type': entry['type'], 'status': 'failed', 'error': redact(str(error))})
|
|
191
|
+
raise
|
|
192
|
+
result['expectations'].append({'type': entry['type'], 'status': 'passed', 'actual': redact(str(actual)), 'at': time.monotonic()})
|
|
193
|
+
if errors:
|
|
194
|
+
raise RuntimeError('page error: ' + '; '.join(errors))
|
|
195
|
+
result.update(verified_at=time.monotonic(), trim_wait=bool(beat.get('trim_wait')), min_reading=max(beat.get('min_reading', 0), board.get('caption', {}).get('min_seconds', 0)))
|
|
196
|
+
shot = out.parent / (out.stem + '.evidence') / (current + '.png')
|
|
197
|
+
shot.parent.mkdir(exist_ok=True)
|
|
198
|
+
await page.screenshot(path=str(shot))
|
|
199
|
+
result.update(status='passed' if beat.get('expect') else 'unverified', screenshot=str(shot.relative_to(out.parent)), start=start)
|
|
200
|
+
await asyncio.sleep(max(beat.get('hold', 1), beat.get('min_reading', 0), board.get('caption', {}).get('min_seconds', 0) if beat.get('caption') else 0, spoken.get(current, ('', 0))[1] + .3 - (time.monotonic() - start)))
|
|
201
|
+
result['end'] = time.monotonic()
|
|
202
|
+
stop = time.monotonic()
|
|
203
|
+
await cdp.send('Page.stopScreencast')
|
|
204
|
+
cdp.remove_listener('Page.screencastFrame', frame_handler)
|
|
205
|
+
page.remove_listener('response', resource_handler)
|
|
206
|
+
if frame_tasks:
|
|
207
|
+
await asyncio.gather(*frame_tasks)
|
|
208
|
+
frames.append((stop, None))
|
|
209
|
+
if pending:
|
|
210
|
+
await asyncio.gather(*pending)
|
|
211
|
+
if errors:
|
|
212
|
+
raise RuntimeError('page error: ' + '; '.join(errors))
|
|
213
|
+
metadata['status'] = 'completed'
|
|
214
|
+
except Exception as e:
|
|
215
|
+
if metadata['beats']:
|
|
216
|
+
metadata['beats'][-1]['status'] = 'failed'
|
|
217
|
+
failure = out.with_suffix('.failure.png')
|
|
218
|
+
try:
|
|
219
|
+
await page.screenshot(path=str(failure))
|
|
220
|
+
metadata['failure_screenshot'] = str(failure.relative_to(out.parent))
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
raise RuntimeError(f'beat {current!r} failed: {redact(str(e))}') from None
|
|
224
|
+
finally:
|
|
225
|
+
cdp.remove_listener('Page.screencastFrame', frame_handler)
|
|
226
|
+
page.remove_listener('response', resource_handler)
|
|
227
|
+
if frame_tasks:
|
|
228
|
+
await asyncio.gather(*frame_tasks, return_exceptions=True)
|
|
229
|
+
if pending:
|
|
230
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
231
|
+
await context.close()
|
|
232
|
+
await browser.close()
|
|
233
|
+
metadata['resources'] = resources
|
|
234
|
+
if not len(frames):
|
|
235
|
+
raise ValueError('no frames captured')
|
|
236
|
+
t0 = frames[0][0]
|
|
237
|
+
for result in metadata['beats']:
|
|
238
|
+
for key in ('start', 'end', 'verified_at'):
|
|
239
|
+
if key in result:
|
|
240
|
+
result[key] = max(0, result[key] - t0)
|
|
241
|
+
for check in result['expectations']:
|
|
242
|
+
check['at'] = max(0, check['at'] - t0)
|
|
243
|
+
times = [t for t, _ in frames]
|
|
244
|
+
metadata['capture'] = {'css_width': w, 'css_height': h, 'pixel_ratio': cap_dpr,
|
|
245
|
+
'frames': len(frames)-1, 'max_gap_seconds': max((b-a for a,b in zip(times,times[1:])), default=0), 'seed': board.get('seed', 1), 'locale': 'en-US', 'timezone': 'UTC',
|
|
246
|
+
'theme': a.theme or board.get('theme', 'dark'), 'mobile': bool(a.mobile or board.get('mobile'))}
|
|
247
|
+
metadata['events'] = [{'t': max(0, t-t0), **event} for t, event in events]
|
|
248
|
+
out.with_suffix('.beats.json').write_text(json.dumps({name: max(0, at-t0) for name, at in beats.items()}, indent=2))
|
|
249
|
+
out.with_suffix('.events.json').write_text(json.dumps(metadata['events'], indent=2))
|
|
250
|
+
out.with_suffix('.assets.json').write_text(json.dumps(sorted(set(assets)), indent=2))
|
|
251
|
+
out.with_suffix('.voice.json').unlink(missing_ok=True)
|
|
252
|
+
if spoken:
|
|
253
|
+
spoken = {name: line for name, line in spoken.items() if name in beats}
|
|
254
|
+
out.with_suffix('.voice.json').write_text(json.dumps([{'beat': name, 't': max(0, beats[name]-t0), 'file': f, 'seconds': seconds, 'text': board['voice'][name]} for name, (f, seconds) in spoken.items()], indent=2))
|
|
255
|
+
temp = out.with_name(out.stem + '.partial.mp4')
|
|
256
|
+
if z:
|
|
257
|
+
zoom.render(frames, events, temp, w, h, int(w*dpr)//2*2, int(h*dpr)//2*2, z)
|
|
258
|
+
else:
|
|
259
|
+
web.encode(frames, temp, size=f'{int(w*dpr)//2*2}:{int(h*dpr)//2*2}')
|
|
260
|
+
temp.replace(out)
|
|
261
|
+
metadata['sha256'] = digest(out)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def main(a):
|
|
265
|
+
board = story.load(a.storyboard)
|
|
266
|
+
web.parse_size(a.size or board.get('size', '1280x720@1'))
|
|
267
|
+
out = Path(a.out)
|
|
268
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
metadata = {'schema_version': 1, 'video': out.name, 'status': 'failed', 'story_id': board.get('id', Path(a.storyboard).stem), 'story_sha256': digest(a.storyboard), 'beats': []}
|
|
270
|
+
from reshoot.voice import prepare
|
|
271
|
+
spoken = prepare(board['voice'], os.environ.get('RESHOOT_VOICE_CACHE', out.with_suffix('.voice')), board.get('voice_engine')) if board.get('voice') else {}
|
|
272
|
+
try:
|
|
273
|
+
with Frames() as frames:
|
|
274
|
+
asyncio.run(asyncio.wait_for(capture(board, a, frames, out, metadata, spoken), board.get('timeout', 600)))
|
|
275
|
+
except (Exception, KeyboardInterrupt) as e:
|
|
276
|
+
metadata['error'] = redact(str(e)) or type(e).__name__
|
|
277
|
+
raise ValueError(metadata['error']) from None
|
|
278
|
+
finally:
|
|
279
|
+
out.with_suffix('.take.json').write_text(json.dumps(metadata, indent=2))
|
|
280
|
+
print('wrote', out)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Skills for repeatable demo videos
|
|
2
|
+
|
|
3
|
+
Give a coding agent the workflow for making a demo, updating it when the app
|
|
4
|
+
changes, and checking the result before sharing. Reshoot's skills guide the
|
|
5
|
+
agent through the work; the reshoot CLI records and edits the media.
|
|
6
|
+
|
|
7
|
+
For a first video, use the [agent quickstart](../guide/agent-quickstart.md).
|
|
8
|
+
Paste its prompt into Codex or Claude Code in your app repository or a demo work
|
|
9
|
+
folder and let the agent handle setup. Supply source code, a running URL or an
|
|
10
|
+
installed macOS app with a demo window. Source code is optional.
|
|
11
|
+
|
|
12
|
+
## Start with three skills
|
|
13
|
+
|
|
14
|
+
| Skill | Use it to | Supply |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| [reshoot-demo](reshoot-demo/SKILL.md) | Choose the capture route, make a first clip, inspect a test take and prepare final exports | App source, a web URL or a native macOS app; the task to show |
|
|
17
|
+
| [reshoot-refresh](reshoot-refresh/SKILL.md) | Record an existing task again after an app change and compare the result | Configuration and exact previous run, or native recipe and previous media |
|
|
18
|
+
| [reshoot-review](reshoot-review/SKILL.md) | Inspect exports, expected results, readability, scan coverage and comparisons | Current and optional previous run, or standalone media and check reports |
|
|
19
|
+
|
|
20
|
+
Invoke the skill with the task in the same message. For example, in Codex:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
$reshoot-demo
|
|
24
|
+
Show adding an issue and finding it by its label. Use synthetic demo data.
|
|
25
|
+
Create a short captioned MP4 and a README GIF for this app.
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
In Claude Code, use `/reshoot-demo`. For the plugin, use
|
|
29
|
+
`/reshoot:reshoot-demo`. Apply the same prefix convention to refresh and review.
|
|
30
|
+
|
|
31
|
+
## Install the package
|
|
32
|
+
|
|
33
|
+
Install the [CLI and platform dependencies](../guide/install.md), then choose
|
|
34
|
+
one route:
|
|
35
|
+
|
|
36
|
+
| Route | Installation | Included skills |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| Codex, project-local | Run `reshoot skills codex --directory .` in the app or demo folder | Three primary skills in `.agents/skills/` |
|
|
39
|
+
| Claude Code, project-local | Run `reshoot skills claude --directory .` in the app or demo folder | Three primary skills in `.claude/skills/` |
|
|
40
|
+
| Claude Code plugin | Run the two commands below inside Claude Code | All ten skills |
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
/plugin marketplace add mumit/reshoot
|
|
44
|
+
/plugin install reshoot@reshoot
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Follow Claude Code's installation and reload prompts. The plugin uses namespaced
|
|
48
|
+
invocations such as `/reshoot:reshoot-demo`. The skills need the CLI and recording
|
|
49
|
+
tools for every route. See [setup and discovery](../guide/agent-quickstart.md#install-the-skills-yourself).
|
|
50
|
+
|
|
51
|
+
The CLI bundles the same primary skill source used by the plugin. It refuses
|
|
52
|
+
to overwrite existing project skills. Upgrading the CLI does not update those
|
|
53
|
+
copies; preserve local edits and compare a new bundle before replacing them.
|
|
54
|
+
|
|
55
|
+
## Use the supporting skills
|
|
56
|
+
|
|
57
|
+
The plugin also includes focused workflows for individual parts of a shoot:
|
|
58
|
+
|
|
59
|
+
| Skill | Purpose |
|
|
60
|
+
|---|---|
|
|
61
|
+
| [plan-demo](plan-demo/SKILL.md) | Choose the story, result, captions and recording format |
|
|
62
|
+
| [record-web](record-web/SKILL.md) | Record a browser page from a storyboard |
|
|
63
|
+
| [record-terminal](record-terminal/SKILL.md) | Record a CLI or agent with VHS |
|
|
64
|
+
| [record-window](record-window/SKILL.md) | Record one macOS app window |
|
|
65
|
+
| [check-privacy](check-privacy/SKILL.md) | Generate and inspect scan sheets and findings |
|
|
66
|
+
| [cut-video](cut-video/SKILL.md) | Edit takes and produce finished exports |
|
|
67
|
+
| [keep-current](keep-current/SKILL.md) | Run a shoot from configuration or prepare CI recording |
|
|
68
|
+
|
|
69
|
+
Use the primary skills for a complete first demo or update. Use a supporting
|
|
70
|
+
skill when working on that part separately. The three-skill CLI installer does
|
|
71
|
+
not install these seven supporting skills.
|
|
72
|
+
|
|
73
|
+
The three primary skills cover all three starting points without requiring the
|
|
74
|
+
supporting bundle. Browser stories support captions and explicit result checks.
|
|
75
|
+
Native takes require app-specific automation or manual interaction, standalone
|
|
76
|
+
editing and manual comparison; browser overlays and assertions do not apply.
|
|
77
|
+
|
|
78
|
+
After the first clip, [explore features](../guide/features.md),
|
|
79
|
+
[customize the source](../guide/customize.md), [build a fuller tutorial](../guide/make-demo.md)
|
|
80
|
+
or [maintain and publish](../guide/publish.md).
|
|
81
|
+
|
|
82
|
+
## Keep the review with the recording
|
|
83
|
+
|
|
84
|
+
The agent can prepare a story, run commands, inspect outputs and explain
|
|
85
|
+
findings. The owner watches the final exports and decides whether the recording
|
|
86
|
+
and scan sheets are acceptable. A successful command or clean scan does not
|
|
87
|
+
make that decision.
|
|
88
|
+
|
|
89
|
+
Use synthetic demo data and page, terminal or single-window capture. Retain
|
|
90
|
+
the previous run for a refresh. Keep publication and repository changes within
|
|
91
|
+
the owner's authorization.
|
|
92
|
+
|
|
93
|
+
## Extend a workflow
|
|
94
|
+
|
|
95
|
+
Keep shared skill source in this directory. The wheel packages it and the CLI
|
|
96
|
+
copies the primary skills into each harness's project location. Follow the
|
|
97
|
+
[contribution guide](../CONTRIBUTING.md#add-a-skill) when adding a skill or fixing
|
|
98
|
+
a recording pitfall.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: check-privacy
|
|
3
|
+
description: Check everything a demo video's takes can show (images the page loaded, frames of each take, anything picked at random) by eye on numbered contact sheets with `reshoot check`, before anything is cut or shared. Use after recording and before cutting or posting.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Check a take for private things
|
|
7
|
+
|
|
8
|
+
Every image a take can show is looked at by a person, at full size, before the video is cut. This is the step that
|
|
9
|
+
keeps a face, a name or a house number out of a public video.
|
|
10
|
+
|
|
11
|
+
## Steps
|
|
12
|
+
|
|
13
|
+
1. Make sheets of everything not checked yet:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
reshoot check takes/web.assets.json takes/web.mp4 takes/cli.mp4 --review takes/review
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Add `--watch-for "Acme,Jane Roe"` (or `--watch-for @words.txt`) with anything of the person's that mustn't
|
|
20
|
+
show: their name, company, street, customers. Sources can be `.assets.json` lists, videos (a frame every
|
|
21
|
+
`--every` seconds, default 3), image files, folders and URLs. The app must still be running, since the assets list holds its URLs. Add anything the app could pick at
|
|
22
|
+
random during a take (suggestions, featured items, a shuffled gallery): the whole set, not only what this take
|
|
23
|
+
showed.
|
|
24
|
+
2. Start with what the scan flagged. It reads the text in supplied images and sampled frames, and flags emails, phone numbers,
|
|
25
|
+
keys and tokens, file paths with a user name, street addresses and the watch-for words. On macOS it also finds
|
|
26
|
+
faces and people. Flagged images come first, with a red label and a red box round each find, and are listed
|
|
27
|
+
with what was found masked (`jane•••`), so nothing private lands in a log.
|
|
28
|
+
3. Look at every sheet (`takes/review/01.jpg` and on) at full size, flagged or not. The scan misses things: text in
|
|
29
|
+
pictures, small or odd fonts, and anything it has no rule for. You may look too, but the person decides. Show
|
|
30
|
+
them the sheets.
|
|
31
|
+
4. For any that fail: `reshoot check --review takes/review --fail 3-2 7-1`. It lists where each came from. Take those
|
|
32
|
+
out of the demo data, then record the take again, since the take still shows them.
|
|
33
|
+
5. When the rest pass: `reshoot check --review takes/review --ok`. Passed images are remembered by a hash of their
|
|
34
|
+
bytes, so the next round shows only what's new.
|
|
35
|
+
6. Repeat until `reshoot check` says everything has been checked.
|
|
36
|
+
|
|
37
|
+
## What fails an image
|
|
38
|
+
|
|
39
|
+
- A person: faces, heads at the edge of the frame, tiny figures far away, reflections in glass, people behind
|
|
40
|
+
windows.
|
|
41
|
+
- Personal details: names, emails, phone numbers, addresses, number plates, house numbers, documents.
|
|
42
|
+
- Secrets: keys, tokens, passwords, URLs with tokens in them.
|
|
43
|
+
- Work details: customers, colleagues, internal tools, unreleased features, private repo names.
|
|
44
|
+
- The computer itself: user names, file paths, other apps, browser tabs, bookmarks, notifications.
|
|
45
|
+
|
|
46
|
+
When in doubt, it fails. In a photo app's demo, a first check missed a straw hat at the edge of a frame, a
|
|
47
|
+
silhouette in a mesh railing and a blurred head in the background.
|
|
48
|
+
|
|
49
|
+
## Pitfalls
|
|
50
|
+
|
|
51
|
+
- **Random picks.** A view that picks at random shows different things in each take. Check its whole pool, or check
|
|
52
|
+
every take.
|
|
53
|
+
- **Deleting doesn't fix the take.** After removing a failed image from the demo data, record again.
|
|
54
|
+
- **Thumbnails hide things.** A small head at the edge only shows at full size. The sheets use up to 1000 px.
|
|
55
|
+
- **Text in the app.** The assets list covers images. Frames from the video cover everything else, so include the
|
|
56
|
+
takes themselves.
|
|
57
|
+
- **A clean scan isn't a pass.** Only `--ok` marks images as checked, and only after a person has looked.
|
|
58
|
+
- **Something private that has to stay in the app:** blur it during the take with `BLUR` or `__demo.blur(sel)`
|
|
59
|
+
(record-web), rather than taking it out of the demo data.
|
|
60
|
+
- **No scan here.** On Linux, text needs Tesseract (`apt install tesseract-ocr`), and faces and people need macOS.
|
|
61
|
+
`reshoot check` says when part of the scan is off. Look extra closely then.
|
|
62
|
+
|
|
63
|
+
## Check status
|
|
64
|
+
|
|
65
|
+
`check` exits 0 when the supplied review items are accepted, 1 when human review is pending, and 2 on a check error. Read `report.json` in the review folder. A missing input or unavailable required text detector is an error, never a pass. Video frames are sampled every three seconds by default; unsampled frames are not covered. `--no-scan` explicitly skips automatic detection and still requires human review.
|
|
66
|
+
|
|
67
|
+
Use `--full` to inspect every decoded video or GIF frame, including brief content. This is slower than the default sampled mode. Original frames are saved in `REVIEW_DIR/originals/`. Scan completion describes coverage, not a guarantee of finding every private detail. Changing scan settings or watch words requires a fresh scan.
|
|
68
|
+
|
|
69
|
+
`--ocr tesseract` or `[check] ocr = "tesseract"` selects text-only OCR explicitly when Vision cannot run. `RESHOOT_OCR` sets the default. Backend failures do not silently fall back; read the reported detector coverage, including unavailable face detection.
|
|
70
|
+
|
|
71
|
+
`check --text-file FINAL.srt` scans exact caption text. Runs also check generated
|
|
72
|
+
subtitle and narration sidecars. Inspect text findings at their recorded cue times.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cut-video
|
|
3
|
+
description: "Cut checked takes into a finished demo video with ffmpeg and the `reshoot.cut` helpers: trims at beat names, title and end cards, sped-up parts, a 9:16 version for phones, and one GIF per section. Use after the takes are recorded and checked."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cut the video
|
|
7
|
+
|
|
8
|
+
Each project has a short edit script, since the order of takes and the words on screen differ. Start from
|
|
9
|
+
`../../examples/notes/edit.py` (from this skill's folder) and copy it into the project. It joins a web take, a
|
|
10
|
+
terminal take on a labeled card, and an end card. Run it with `reshoot edit tools/demo/edit.py
|
|
11
|
+
takes/web.mp4`, which makes `reshoot.cut` importable even when reshoot is installed as a tool.
|
|
12
|
+
|
|
13
|
+
## The helpers
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from reshoot import cut
|
|
17
|
+
|
|
18
|
+
b = cut.beats("takes/web.mp4") # {beat name: seconds}
|
|
19
|
+
cut.trim(src, start, end, out, fade_in=0, fade_out=0.4)
|
|
20
|
+
cut.speed(src, start, end, 8, out) # 8x as fast
|
|
21
|
+
cut.still(png, 3, out) # an image held 3 s, faded in
|
|
22
|
+
cut.place(bg_png, src, x, y, out, width=None) # a video placed on a background
|
|
23
|
+
cut.vertical(src, bg_png, out) # 9:16, the video across the middle
|
|
24
|
+
cut.concat([a, b, c], out) # joined without encoding again
|
|
25
|
+
cut.gif(src, start, end, out, fps=15, width=800)
|
|
26
|
+
cut.last_change(take) # when a terminal take finished
|
|
27
|
+
cut.window_clip(mov, bg_png, x, y, out, width=None) # a window recording, cropped, corners rounded, on a card
|
|
28
|
+
cut.window_box(mov) # (w, h, x, y) of a window in its shadow
|
|
29
|
+
cut.Card(1920, 1080, bg).text(None, 400, "Title", 112, 700).badge(None, 600, "8x speed").save(png)
|
|
30
|
+
|
|
31
|
+
said = cut.lines(take, start, end, offset) # a take's voiceover lines in [start, end), moved to offset
|
|
32
|
+
cut.voice_track(said, wav, total) # those lines laid out on a track `total` seconds long
|
|
33
|
+
cut.add_audio(video, out, voice=wav, music=None, music_volume=0.25) # music loops, fades, ducks under the voice
|
|
34
|
+
cut.write_srt(cut.cues(take, start, end, offset), srt) # subtitles: the voiceover, or else the captions
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`cut.ff(...)` runs any other ffmpeg command. Every clip made by these is 60 fps H.264 in yuv420p with no sound.
|
|
38
|
+
|
|
39
|
+
## Steps
|
|
40
|
+
|
|
41
|
+
1. Keep all the words on screen at the top of the edit script, so they're easy to change.
|
|
42
|
+
2. Cut each web part at its beats: the take's own end beat is usually replaced by a proper end card.
|
|
43
|
+
3. Cut a terminal take at `cut.last_change`. Speed up the waiting part and label it ("8x speed").
|
|
44
|
+
4. Join the parts with `cut.concat`. They must all be the same size: a web take at 1280x720@1.5 is 1920x1080, a VHS
|
|
45
|
+
take is its tape's `Width` and `Height`. Place smaller takes on a full-size card with `cut.place`.
|
|
46
|
+
5. Make the 9:16 version: the landscape video on a 1080x1920 card, with the name above and the link below.
|
|
47
|
+
6. Make one GIF per section, from beat to beat.
|
|
48
|
+
7. Add sound last, to the finished video: `cut.add_audio` with the voice track (if the storyboard had `VOICE`)
|
|
49
|
+
and any music. Give each part's lines its place in the finished video with `offset`: a part that starts 12 s
|
|
50
|
+
in uses `cut.lines(take, a, b, offset=12)`. Write subtitles the same way.
|
|
51
|
+
8. Watch the whole thing, with sound if it has any. Check the end card's spelling and link. Then give the person the files and say where they
|
|
52
|
+
are. Don't post them.
|
|
53
|
+
|
|
54
|
+
## Pitfalls
|
|
55
|
+
|
|
56
|
+
- **Joining clips of different sizes** fails or looks wrong. `cut.concat` refuses them; make every part the same size.
|
|
57
|
+
- **Colors change when joined.** A clip in full color range (a camera clip, or one made outside these helpers)
|
|
58
|
+
makes the clips after it look washed out. `cut.concat` refuses a mix; run such a clip through `cut.trim` first.
|
|
59
|
+
- **Odd sizes.** H.264 in yuv420p needs even widths and heights. Scale to `-2` (as the helpers do).
|
|
60
|
+
- **GIF colours.** The default palette bands badly. `cut.gif` makes a palette for each GIF.
|
|
61
|
+
- **Joined GIF sections.** `cut.gif` normalizes frames before building a palette from the full clip, preserving card/browser joins and static scene changes. Inspect the opening, ending and duration so an omitted or frozen section cannot pass on MP4 playback alone.
|
|
62
|
+
- **Fonts.** Cards use SF on macOS, and DejaVu Sans on Linux if installed.
|
|
63
|
+
- **Faststart.** Joined videos are written with `+faststart`, so they play in a browser before they finish loading.
|
|
64
|
+
- **Joining clips with sound.** `cut.concat` joins video only. Join the silent parts first, then add the sound.
|
|
65
|
+
- **Subtitles and burned-in captions.** Captions on screen can't be read by a screen reader or searched, so the
|
|
66
|
+
`.srt` carries them too. With a voiceover, it carries what's said instead. Upload it with the video where the site
|
|
67
|
+
allows.
|
|
68
|
+
- **Music you can use.** Only use music you have the right to post. Keep it quiet (`music_volume` 0.15 to 0.3).
|
|
69
|
+
- **Limits for posting.** Sites limit size and length, and change them. Check before uploading.
|
|
70
|
+
|
|
71
|
+
## Declarative edits
|
|
72
|
+
|
|
73
|
+
`reshoot edit EDIT.toml TAKE...` accepts schema-version-1 edit files with ordered `[[part]]` entries and output profiles. The packaged project provides an example. Cut helpers write `.timeline.json` mappings; trims, speed changes, placement, and concatenation retain source times. Speed changes need a visible label and cannot shorten narration. Explicit local copies use `reshoot export RUN --directory DIR --file PATH`; hashes must match the report, and existing destinations are not overwritten.
|
|
74
|
+
|
|
75
|
+
Declarative edits can use `trim_waits = true` for beats explicitly marked
|
|
76
|
+
`trim_wait = true` with a passed expectation. Keep `min_reading` long enough to
|
|
77
|
+
read the result. Reshoot preserves speech and labels shortened waits.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: keep-current
|
|
3
|
+
description: Set up a reshoot.toml so one command (`reshoot run`) records a project's whole demo video again, and a GitHub Actions workflow that reshoots it on each release and opens a pull request. Use when someone wants their demo video or README GIF to stay up to date, or asks for demo videos in CI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Keep a demo video current
|
|
7
|
+
|
|
8
|
+
## Rules
|
|
9
|
+
|
|
10
|
+
- CI never publishes anything. The workflow opens a pull request; a person checks the review sheets and merges it.
|
|
11
|
+
- Demo data used in CI is in the repo, so it's as public as the repo. Only put in what anyone may see.
|
|
12
|
+
- Create workflow files locally when authorized. Push or publish only with the owner's permission.
|
|
13
|
+
|
|
14
|
+
## Steps
|
|
15
|
+
|
|
16
|
+
1. **Write `reshoot.toml`** at the project root, from the takes the project already has. `reshoot run --help`
|
|
17
|
+
lists every setting; `../../examples/notes/reshoot.toml` (from this skill's folder) is a full example.
|
|
18
|
+
- `[app]`: the command that starts the app on demo data, and the URL to wait for.
|
|
19
|
+
- `[[take]]`: one per take, `web` (with `out`), `tape`, or `run` for any other command.
|
|
20
|
+
- `[edit]`, `[check]`: the edit script and what to check.
|
|
21
|
+
- `[keep]`: where the finished files go in the repo, such as `docs/demo/`.
|
|
22
|
+
2. **Run it locally:** `reshoot run`. Fix whatever fails. It stops at the first failed step and names it.
|
|
23
|
+
3. **Add the workflow:** copy `../../examples/github/reshoot.yml` to `.github/workflows/reshoot.yml`. Add the steps
|
|
24
|
+
the app needs before it starts (installing packages, making demo data), and change the marked lines. The repo
|
|
25
|
+
must let workflows open pull requests (Settings, Actions, General, "Allow GitHub Actions to create and approve
|
|
26
|
+
pull requests"); it's off by default. Changing it is the person's call.
|
|
27
|
+
4. **Tell the person** what the workflow does, and that they review each pull request's sheets (the
|
|
28
|
+
`reshoot-review` artifact) before merging. Push only with their yes.
|
|
29
|
+
|
|
30
|
+
## Pitfalls
|
|
31
|
+
|
|
32
|
+
- **Fonts differ on Linux,** so text may wrap differently from the local takes. Look at the first CI video closely.
|
|
33
|
+
- **Agent takes** (Claude Code) need an account and key in CI, and vary between runs. Record them locally and keep
|
|
34
|
+
the file instead.
|
|
35
|
+
- **Checks wait on the pull request.** Workflows don't run on a pull request opened by `github-actions[bot]` until
|
|
36
|
+
someone approves them ("action_required"). Tell the person, or use a GitHub App or personal token.
|
|
37
|
+
- **A server already running** on the app's port stops the shoot, so a take can't show the wrong server.
|
|
38
|
+
|
|
39
|
+
The run report and Action outputs distinguish `scan_status` and `review_status`. `ok=true` means execution completed, not approval to publish. A required check error stops the run, and the app remains available until its URL-based checks finish.
|
|
40
|
+
|
|
41
|
+
Versioned runs keep separate candidate files. Compare two explicit runs with `reshoot compare OLD NEW --out FILE`, then generate `reshoot review NEW --previous OLD`. Matching beat IDs do not imply matching results. No new run promotes itself to the baseline.
|