@maccesar/aiskills 1.22.0 → 1.24.0
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.
- package/README.md +84 -75
- package/lib/config.js +1 -0
- package/package.json +1 -1
- package/skills/humaniza/SKILL.md +39 -1
- package/skills/technical-demo-videos/SKILL.md +146 -0
- package/skills/technical-demo-videos/agents/openai.yaml +4 -0
- package/skills/technical-demo-videos/evals/evals.json +62 -0
- package/skills/technical-demo-videos/references/audio-timing.md +40 -0
- package/skills/technical-demo-videos/references/package-contract.md +33 -0
- package/skills/technical-demo-videos/references/publishing-metadata.md +36 -0
- package/skills/technical-demo-videos/references/recording-workflow.md +40 -0
- package/skills/technical-demo-videos/references/story-direction.md +37 -0
- package/skills/technical-demo-videos/references/vertical-social-video.md +24 -0
- package/skills/technical-demo-videos/references/vscode-default-profile.md +49 -0
- package/skills/technical-demo-videos/references/youtube-master.md +42 -0
- package/skills/technical-demo-videos/references/youtube-publishing.md +126 -0
- package/skills/technical-demo-videos/scripts/events_to_cues.py +89 -0
- package/skills/technical-demo-videos/scripts/normalize_youtube_master.py +210 -0
- package/skills/technical-demo-videos/scripts/recording_runtime.py +427 -0
- package/skills/technical-demo-videos/scripts/youtube_publish.py +517 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Create and verify a stable YouTube upload master from an approved edit."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from fractions import Fraction
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
STANDARD_BITRATES = {
|
|
17
|
+
2160: {
|
|
18
|
+
'standard': ('35M', 35_000_000),
|
|
19
|
+
'high': ('53M', 53_000_000)
|
|
20
|
+
},
|
|
21
|
+
1440: {
|
|
22
|
+
'standard': ('16M', 16_000_000),
|
|
23
|
+
'high': ('24M', 24_000_000)
|
|
24
|
+
},
|
|
25
|
+
1080: {
|
|
26
|
+
'standard': ('8M', 8_000_000),
|
|
27
|
+
'high': ('12M', 12_000_000)
|
|
28
|
+
},
|
|
29
|
+
720: {
|
|
30
|
+
'standard': ('5M', 5_000_000),
|
|
31
|
+
'high': ('7.5M', 7_500_000)
|
|
32
|
+
},
|
|
33
|
+
480: {
|
|
34
|
+
'standard': ('2.5M', 2_500_000),
|
|
35
|
+
'high': ('4M', 4_000_000)
|
|
36
|
+
},
|
|
37
|
+
360: {
|
|
38
|
+
'standard': ('1M', 1_000_000),
|
|
39
|
+
'high': ('1.5M', 1_500_000)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_args():
|
|
45
|
+
parser = argparse.ArgumentParser()
|
|
46
|
+
parser.add_argument('input', type=Path)
|
|
47
|
+
parser.add_argument('output', type=Path)
|
|
48
|
+
parser.add_argument('--fps', type=int, default=30)
|
|
49
|
+
parser.add_argument('--video-bitrate')
|
|
50
|
+
return parser.parse_args()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def probe(path):
|
|
54
|
+
result = subprocess.run([
|
|
55
|
+
'ffprobe', '-v', 'error', '-show_streams', '-show_format',
|
|
56
|
+
'-of', 'json', str(path)
|
|
57
|
+
], check=True, capture_output=True, text=True)
|
|
58
|
+
return json.loads(result.stdout)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stream(payload, kind):
|
|
62
|
+
selected = next(
|
|
63
|
+
(item for item in payload['streams'] if item.get('codec_type') == kind),
|
|
64
|
+
None
|
|
65
|
+
)
|
|
66
|
+
if selected is None:
|
|
67
|
+
raise RuntimeError(f'{kind} stream is required')
|
|
68
|
+
return selected
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_bitrate(value):
|
|
72
|
+
suffixes = {'K': 1_000, 'M': 1_000_000}
|
|
73
|
+
suffix = value[-1].upper()
|
|
74
|
+
if suffix in suffixes:
|
|
75
|
+
return int(float(value[:-1]) * suffixes[suffix])
|
|
76
|
+
return int(value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def default_bitrate(height, fps):
|
|
80
|
+
frame_rate_class = 'high' if fps >= 48 else 'standard'
|
|
81
|
+
for threshold, values in STANDARD_BITRATES.items():
|
|
82
|
+
if height >= threshold:
|
|
83
|
+
return values[frame_rate_class]
|
|
84
|
+
return (
|
|
85
|
+
('1.5M', 1_500_000) if frame_rate_class == 'high'
|
|
86
|
+
else ('1M', 1_000_000)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def has_faststart(path):
|
|
91
|
+
with path.open('rb') as handle:
|
|
92
|
+
header = handle.read(min(path.stat().st_size, 16 * 1024 * 1024))
|
|
93
|
+
moov = header.find(b'moov')
|
|
94
|
+
mdat = header.find(b'mdat')
|
|
95
|
+
return moov >= 0 and mdat >= 0 and moov < mdat
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate_master(path, expected_width, expected_height, fps, bitrate):
|
|
99
|
+
payload = probe(path)
|
|
100
|
+
video = stream(payload, 'video')
|
|
101
|
+
audio = stream(payload, 'audio')
|
|
102
|
+
errors = []
|
|
103
|
+
if (video.get('width'), video.get('height')) != (
|
|
104
|
+
expected_width, expected_height
|
|
105
|
+
):
|
|
106
|
+
errors.append('resolution changed during normalization')
|
|
107
|
+
if video.get('codec_name') != 'h264' or video.get('profile') != 'High':
|
|
108
|
+
errors.append('video must be H.264 High Profile')
|
|
109
|
+
if video.get('pix_fmt') != 'yuv420p':
|
|
110
|
+
errors.append('video pixel format must be yuv420p')
|
|
111
|
+
if any(video.get(key) != 'bt709' for key in (
|
|
112
|
+
'color_space', 'color_transfer', 'color_primaries'
|
|
113
|
+
)):
|
|
114
|
+
errors.append('video color metadata must be BT.709')
|
|
115
|
+
actual_fps = Fraction(video['avg_frame_rate'])
|
|
116
|
+
if actual_fps != fps:
|
|
117
|
+
errors.append(f'video frame rate must be constant {fps} FPS')
|
|
118
|
+
actual_bitrate = int(video.get('bit_rate', 0))
|
|
119
|
+
if actual_bitrate < round(bitrate * 0.85):
|
|
120
|
+
errors.append(
|
|
121
|
+
f'video bitrate {actual_bitrate} is below the quality floor {bitrate}'
|
|
122
|
+
)
|
|
123
|
+
if audio.get('codec_name') != 'aac':
|
|
124
|
+
errors.append('audio codec must be AAC')
|
|
125
|
+
if int(audio.get('sample_rate', 0)) != 48_000:
|
|
126
|
+
errors.append('audio sample rate must be 48 kHz')
|
|
127
|
+
if int(audio.get('channels', 0)) != 2:
|
|
128
|
+
errors.append('audio must be stereo')
|
|
129
|
+
if 'mp4' not in payload['format'].get('format_name', ''):
|
|
130
|
+
errors.append('container must be MP4')
|
|
131
|
+
if not has_faststart(path):
|
|
132
|
+
errors.append('MP4 moov atom must precede media data for fast start')
|
|
133
|
+
if errors:
|
|
134
|
+
raise RuntimeError('; '.join(errors))
|
|
135
|
+
return {
|
|
136
|
+
'width': video['width'],
|
|
137
|
+
'height': video['height'],
|
|
138
|
+
'fps': float(actual_fps),
|
|
139
|
+
'videoBitrate': actual_bitrate,
|
|
140
|
+
'videoCodec': video['codec_name'],
|
|
141
|
+
'videoProfile': video['profile'],
|
|
142
|
+
'pixelFormat': video['pix_fmt'],
|
|
143
|
+
'color': 'bt709',
|
|
144
|
+
'audioCodec': audio['codec_name'],
|
|
145
|
+
'audioSampleRate': int(audio['sample_rate']),
|
|
146
|
+
'fastStart': True
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main():
|
|
151
|
+
args = parse_args()
|
|
152
|
+
source = args.input.resolve()
|
|
153
|
+
output = args.output.resolve()
|
|
154
|
+
if not source.is_file():
|
|
155
|
+
raise RuntimeError(f'input not found: {source}')
|
|
156
|
+
if output.exists():
|
|
157
|
+
raise RuntimeError(f'refusing to overwrite: {output}')
|
|
158
|
+
if output.suffix.lower() != '.mp4':
|
|
159
|
+
raise RuntimeError('output must use an .mp4 extension')
|
|
160
|
+
if args.fps not in {24, 25, 30, 48, 50, 60}:
|
|
161
|
+
raise RuntimeError('fps must be one of 24, 25, 30, 48, 50, or 60')
|
|
162
|
+
for tool in ('ffmpeg', 'ffprobe'):
|
|
163
|
+
if shutil.which(tool) is None:
|
|
164
|
+
raise RuntimeError(f'missing required tool: {tool}')
|
|
165
|
+
|
|
166
|
+
source_payload = probe(source)
|
|
167
|
+
source_video = stream(source_payload, 'video')
|
|
168
|
+
width = int(source_video['width'])
|
|
169
|
+
height = int(source_video['height'])
|
|
170
|
+
bitrate_text, bitrate = default_bitrate(height, args.fps)
|
|
171
|
+
if args.video_bitrate:
|
|
172
|
+
bitrate_text = args.video_bitrate
|
|
173
|
+
bitrate = parse_bitrate(bitrate_text)
|
|
174
|
+
gop = max(1, round(args.fps / 2))
|
|
175
|
+
temporary = output.with_name(f'.{output.stem}.encoding.mp4')
|
|
176
|
+
temporary.unlink(missing_ok=True)
|
|
177
|
+
|
|
178
|
+
command = [
|
|
179
|
+
'ffmpeg', '-hide_banner', '-y', '-i', str(source),
|
|
180
|
+
'-map', '0:v:0', '-map', '0:a:0?',
|
|
181
|
+
'-vf', f'fps={args.fps},format=yuv420p',
|
|
182
|
+
'-fps_mode', 'cfr', '-c:v', 'libx264', '-preset', 'fast',
|
|
183
|
+
'-profile:v', 'high', '-b:v', bitrate_text,
|
|
184
|
+
'-g', str(gop), '-keyint_min', str(gop), '-sc_threshold', '0',
|
|
185
|
+
'-bf', '2',
|
|
186
|
+
'-x264-params', (
|
|
187
|
+
'force-cfr=1:colorprim=bt709:transfer=bt709:colormatrix=bt709'
|
|
188
|
+
),
|
|
189
|
+
'-color_primaries', 'bt709', '-color_trc', 'bt709',
|
|
190
|
+
'-colorspace', 'bt709',
|
|
191
|
+
'-c:a', 'aac', '-b:a', '384k', '-ar', '48000', '-ac', '2',
|
|
192
|
+
'-movflags', '+faststart', str(temporary)
|
|
193
|
+
]
|
|
194
|
+
try:
|
|
195
|
+
subprocess.run(command, check=True)
|
|
196
|
+
report = validate_master(
|
|
197
|
+
temporary, width, height, args.fps, bitrate
|
|
198
|
+
)
|
|
199
|
+
temporary.replace(output)
|
|
200
|
+
finally:
|
|
201
|
+
temporary.unlink(missing_ok=True)
|
|
202
|
+
print(json.dumps({'output': str(output), **report}, indent=2))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == '__main__':
|
|
206
|
+
try:
|
|
207
|
+
main()
|
|
208
|
+
except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as exc:
|
|
209
|
+
print(f'error: {exc}', file=sys.stderr)
|
|
210
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reusable primitives copied into an approved technical-video package."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from statistics import median
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_VSCODE_PROFILE = {
|
|
19
|
+
'logical_display': (1920, 1080),
|
|
20
|
+
'minimum_window_size': (1850, 1000),
|
|
21
|
+
'auxiliary_panel_key_code': 19,
|
|
22
|
+
'terminal_open_delay': 1.5,
|
|
23
|
+
'terminal_drag_x': 645,
|
|
24
|
+
'terminal_target_y': 720,
|
|
25
|
+
'terminal_search_y': (90, 950)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
SWIFT_DRAG_SOURCE = r'''
|
|
29
|
+
import CoreGraphics
|
|
30
|
+
import Foundation
|
|
31
|
+
|
|
32
|
+
guard CommandLine.arguments.count == 5,
|
|
33
|
+
let startX = Double(CommandLine.arguments[1]),
|
|
34
|
+
let startY = Double(CommandLine.arguments[2]),
|
|
35
|
+
let endX = Double(CommandLine.arguments[3]),
|
|
36
|
+
let endY = Double(CommandLine.arguments[4]) else {
|
|
37
|
+
exit(2)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let start = CGPoint(x: startX, y: startY)
|
|
41
|
+
let end = CGPoint(x: endX, y: endY)
|
|
42
|
+
CGEvent(mouseEventSource: nil, mouseType: .mouseMoved,
|
|
43
|
+
mouseCursorPosition: start, mouseButton: .left)?.post(tap: .cghidEventTap)
|
|
44
|
+
usleep(100_000)
|
|
45
|
+
CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown,
|
|
46
|
+
mouseCursorPosition: start, mouseButton: .left)?.post(tap: .cghidEventTap)
|
|
47
|
+
for step in 1...20 {
|
|
48
|
+
let progress = Double(step) / 20.0
|
|
49
|
+
let point = CGPoint(
|
|
50
|
+
x: startX + ((endX - startX) * progress),
|
|
51
|
+
y: startY + ((endY - startY) * progress)
|
|
52
|
+
)
|
|
53
|
+
CGEvent(mouseEventSource: nil, mouseType: .leftMouseDragged,
|
|
54
|
+
mouseCursorPosition: point, mouseButton: .left)?.post(tap: .cghidEventTap)
|
|
55
|
+
usleep(15_000)
|
|
56
|
+
}
|
|
57
|
+
CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp,
|
|
58
|
+
mouseCursorPosition: end, mouseButton: .left)?.post(tap: .cghidEventTap)
|
|
59
|
+
'''
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MacVsCodeRecording:
|
|
63
|
+
def __init__(
|
|
64
|
+
self, source_project, output_video, output_events, slug,
|
|
65
|
+
copies_root=None, display_number=1
|
|
66
|
+
):
|
|
67
|
+
self.source_project = Path(source_project).resolve()
|
|
68
|
+
self.output_video = Path(output_video).resolve()
|
|
69
|
+
self.output_events = Path(output_events).resolve()
|
|
70
|
+
self.slug = slug
|
|
71
|
+
configured_root = copies_root or os.environ.get('TECHNICAL_DEMO_COPIES_ROOT')
|
|
72
|
+
self.copies_root = Path(configured_root).expanduser().resolve() if configured_root else Path.home() / 'TechnicalDemos'
|
|
73
|
+
self.display_number = int(display_number)
|
|
74
|
+
self.temp_root = None
|
|
75
|
+
self.project = None
|
|
76
|
+
self.window_slug = None
|
|
77
|
+
self.capture = None
|
|
78
|
+
self.drag_helper = None
|
|
79
|
+
self.events = []
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _apple_string(value):
|
|
83
|
+
return str(value).replace('\\', '\\\\').replace('"', '\\"')
|
|
84
|
+
|
|
85
|
+
def preflight(self, required_files=(), forbidden_files=()):
|
|
86
|
+
for tool in (
|
|
87
|
+
'code', 'magick', 'osascript', 'screencapture', 'swift', 'swiftc'
|
|
88
|
+
):
|
|
89
|
+
if shutil.which(tool) is None:
|
|
90
|
+
raise RuntimeError(f'missing required tool: {tool}')
|
|
91
|
+
if not self.source_project.is_dir():
|
|
92
|
+
raise RuntimeError(f'project not found: {self.source_project}')
|
|
93
|
+
missing = [path for path in required_files if not (self.source_project / path).exists()]
|
|
94
|
+
if missing:
|
|
95
|
+
raise RuntimeError('missing project files: ' + ', '.join(missing))
|
|
96
|
+
present = [path for path in forbidden_files if (self.source_project / path).exists()]
|
|
97
|
+
if present:
|
|
98
|
+
raise RuntimeError('source already contains generated files: ' + ', '.join(present))
|
|
99
|
+
for artifact in (self.output_video, self.output_events):
|
|
100
|
+
if artifact.exists():
|
|
101
|
+
raise RuntimeError(f'refusing to overwrite: {artifact}')
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def logical_display_size():
|
|
105
|
+
source = (
|
|
106
|
+
'import AppKit; '
|
|
107
|
+
'let f = NSScreen.main!.frame; '
|
|
108
|
+
'print(Int(f.width), Int(f.height))'
|
|
109
|
+
)
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
['swift', '-e', source], check=True, capture_output=True, text=True
|
|
112
|
+
)
|
|
113
|
+
width, height = result.stdout.strip().split()
|
|
114
|
+
return int(width), int(height)
|
|
115
|
+
|
|
116
|
+
def create_copy(self):
|
|
117
|
+
copies_root = self.copies_root
|
|
118
|
+
copies_root.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
if not copies_root.is_dir():
|
|
120
|
+
raise RuntimeError(f'recording copies folder not found: {copies_root}')
|
|
121
|
+
self.temp_root = Path(tempfile.mkdtemp(prefix=f'{self.slug}-recording.'))
|
|
122
|
+
self.project = Path(tempfile.mkdtemp(prefix=f'{self.slug}.', dir=copies_root))
|
|
123
|
+
self.window_slug = self.project.name
|
|
124
|
+
shutil.copytree(self.source_project, self.project, dirs_exist_ok=True)
|
|
125
|
+
self.drag_helper = self.temp_root / 'drag'
|
|
126
|
+
subprocess.run(
|
|
127
|
+
['swiftc', '-o', str(self.drag_helper), '-'],
|
|
128
|
+
input=SWIFT_DRAG_SOURCE, check=True, capture_output=True, text=True
|
|
129
|
+
)
|
|
130
|
+
return self.project
|
|
131
|
+
|
|
132
|
+
def open_vscode(self):
|
|
133
|
+
subprocess.run(['code', '-n', str(self.project)], check=True)
|
|
134
|
+
|
|
135
|
+
def targeted(self, body, capture_output=False):
|
|
136
|
+
slug = self._apple_string(self.window_slug)
|
|
137
|
+
script = f'''
|
|
138
|
+
set slug to "{slug}"
|
|
139
|
+
tell application "System Events"
|
|
140
|
+
tell process "Code"
|
|
141
|
+
set targetWindow to missing value
|
|
142
|
+
repeat with candidateWindow in windows
|
|
143
|
+
if name of candidateWindow contains slug then
|
|
144
|
+
set targetWindow to candidateWindow
|
|
145
|
+
exit repeat
|
|
146
|
+
end if
|
|
147
|
+
end repeat
|
|
148
|
+
if targetWindow is missing value then error "Target window not found: " & slug
|
|
149
|
+
perform action "AXRaise" of targetWindow
|
|
150
|
+
set frontmost to true
|
|
151
|
+
delay 0.15
|
|
152
|
+
if name of front window does not contain slug then error "Wrong front window"
|
|
153
|
+
{body}
|
|
154
|
+
end tell
|
|
155
|
+
end tell
|
|
156
|
+
'''
|
|
157
|
+
result = subprocess.run(
|
|
158
|
+
['osascript', '-e', script], check=True,
|
|
159
|
+
capture_output=capture_output, text=True
|
|
160
|
+
)
|
|
161
|
+
return result.stdout.strip() if capture_output else ''
|
|
162
|
+
|
|
163
|
+
def wait_for_window(self, timeout=30):
|
|
164
|
+
deadline = time.monotonic() + timeout
|
|
165
|
+
while time.monotonic() < deadline:
|
|
166
|
+
try:
|
|
167
|
+
self.targeted('return name of front window', True)
|
|
168
|
+
return
|
|
169
|
+
except subprocess.CalledProcessError:
|
|
170
|
+
time.sleep(0.4)
|
|
171
|
+
raise RuntimeError('VS Code window did not appear')
|
|
172
|
+
|
|
173
|
+
def assert_window_size(self, minimum_size):
|
|
174
|
+
raw_size = self.targeted(
|
|
175
|
+
'set s to size of front window\n'
|
|
176
|
+
'return (item 1 of s as text) & "," & (item 2 of s as text)',
|
|
177
|
+
True
|
|
178
|
+
)
|
|
179
|
+
width, height = [int(value) for value in raw_size.split(',')]
|
|
180
|
+
if width < minimum_size[0] or height < minimum_size[1]:
|
|
181
|
+
raise RuntimeError(
|
|
182
|
+
f'VS Code window is {width}x{height}; minimum is '
|
|
183
|
+
f'{minimum_size[0]}x{minimum_size[1]}'
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def window_geometry(self):
|
|
187
|
+
raw_geometry = self.targeted(
|
|
188
|
+
'set p to position of front window\n'
|
|
189
|
+
'set s to size of front window\n'
|
|
190
|
+
'return (item 1 of p as text) & "," & '
|
|
191
|
+
'(item 2 of p as text) & "," & '
|
|
192
|
+
'(item 1 of s as text) & "," & (item 2 of s as text)',
|
|
193
|
+
True
|
|
194
|
+
)
|
|
195
|
+
return tuple(int(value) for value in raw_geometry.split(','))
|
|
196
|
+
|
|
197
|
+
def open_terminal(self):
|
|
198
|
+
self.targeted(
|
|
199
|
+
'click menu item "New Terminal" of menu 1 of '
|
|
200
|
+
'menu bar item "Terminal" of menu bar 1'
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def drag(self, start, end):
|
|
204
|
+
self.targeted('return name of front window', True)
|
|
205
|
+
subprocess.run([
|
|
206
|
+
str(self.drag_helper), str(start[0]), str(start[1]),
|
|
207
|
+
str(end[0]), str(end[1])
|
|
208
|
+
], check=True)
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _column_pixels(image_path, x, height):
|
|
212
|
+
result = subprocess.run([
|
|
213
|
+
'magick', str(image_path), '-crop', f'1x{height}+{x}+0',
|
|
214
|
+
'-depth', '8', 'txt:-'
|
|
215
|
+
], check=True, capture_output=True, text=True)
|
|
216
|
+
pattern = re.compile(r'^0,(\d+): \((\d+),(\d+),(\d+)')
|
|
217
|
+
pixels = []
|
|
218
|
+
for line in result.stdout.splitlines():
|
|
219
|
+
match = pattern.match(line)
|
|
220
|
+
if match:
|
|
221
|
+
pixels.append(tuple(int(value) for value in match.groups()[1:]))
|
|
222
|
+
if len(pixels) != height:
|
|
223
|
+
raise RuntimeError(
|
|
224
|
+
f'could not read screenshot column at x={x}: '
|
|
225
|
+
f'expected {height} pixels, found {len(pixels)}'
|
|
226
|
+
)
|
|
227
|
+
return pixels
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _average_color(pixels, start, end):
|
|
231
|
+
count = end - start
|
|
232
|
+
return tuple(
|
|
233
|
+
sum(pixels[index][channel] for index in range(start, end)) / count
|
|
234
|
+
for channel in range(3)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def detect_terminal_divider(self, screenshot_path, search_range):
|
|
238
|
+
window_x, window_y, window_width, window_height = self.window_geometry()
|
|
239
|
+
dimensions = subprocess.run([
|
|
240
|
+
'magick', 'identify', '-format', '%w,%h', str(screenshot_path)
|
|
241
|
+
], check=True, capture_output=True, text=True).stdout.strip()
|
|
242
|
+
image_width, image_height = [int(value) for value in dimensions.split(',')]
|
|
243
|
+
logical_width, logical_height = self.logical_display_size()
|
|
244
|
+
scale_x = image_width / logical_width
|
|
245
|
+
scale_y = image_height / logical_height
|
|
246
|
+
if abs(scale_x - scale_y) > 0.01:
|
|
247
|
+
raise RuntimeError('display screenshot has inconsistent pixel scaling')
|
|
248
|
+
scale = scale_x
|
|
249
|
+
|
|
250
|
+
logical_xs = [
|
|
251
|
+
window_x + round(window_width * fraction)
|
|
252
|
+
for fraction in (0.20, 0.35, 0.55)
|
|
253
|
+
]
|
|
254
|
+
columns = [
|
|
255
|
+
self._column_pixels(
|
|
256
|
+
screenshot_path, round(logical_x * scale), image_height
|
|
257
|
+
)
|
|
258
|
+
for logical_x in logical_xs
|
|
259
|
+
]
|
|
260
|
+
first_y = max(window_y + search_range[0], window_y + 80)
|
|
261
|
+
last_y = min(
|
|
262
|
+
window_y + search_range[1], window_y + window_height - 100
|
|
263
|
+
)
|
|
264
|
+
start = max(round(first_y * scale), 16)
|
|
265
|
+
end = min(round(last_y * scale), image_height - 16)
|
|
266
|
+
|
|
267
|
+
best = None
|
|
268
|
+
for y in range(start, end):
|
|
269
|
+
column_scores = []
|
|
270
|
+
for pixels in columns:
|
|
271
|
+
above = self._average_color(pixels, y - 12, y - 4)
|
|
272
|
+
below = self._average_color(pixels, y + 4, y + 12)
|
|
273
|
+
distance = sum(
|
|
274
|
+
(above[channel] - below[channel]) ** 2
|
|
275
|
+
for channel in range(3)
|
|
276
|
+
) ** 0.5
|
|
277
|
+
column_scores.append(distance)
|
|
278
|
+
score = median(column_scores)
|
|
279
|
+
if min(column_scores) < score * 0.6:
|
|
280
|
+
continue
|
|
281
|
+
if best is None or score > best[0]:
|
|
282
|
+
best = (score, y)
|
|
283
|
+
|
|
284
|
+
if best is None or best[0] < 6:
|
|
285
|
+
raise RuntimeError(
|
|
286
|
+
'could not locate the horizontal terminal divider with confidence'
|
|
287
|
+
)
|
|
288
|
+
return round(best[1] / scale)
|
|
289
|
+
|
|
290
|
+
def resize_terminal_panel(self, x, target_y, search_range=(90, 950)):
|
|
291
|
+
if self.temp_root is None:
|
|
292
|
+
raise RuntimeError('disposable project has not been created')
|
|
293
|
+
before = self.temp_root / 'terminal-before.png'
|
|
294
|
+
after = self.temp_root / 'terminal-after.png'
|
|
295
|
+
self.targeted('return name of front window', True)
|
|
296
|
+
subprocess.run([
|
|
297
|
+
'/usr/sbin/screencapture', '-x', f'-D{self.display_number}', str(before)
|
|
298
|
+
], check=True)
|
|
299
|
+
handle_offset = 5
|
|
300
|
+
start_y = (
|
|
301
|
+
self.detect_terminal_divider(before, search_range) + handle_offset
|
|
302
|
+
)
|
|
303
|
+
if abs(start_y - target_y) > 4:
|
|
304
|
+
self.drag((x, start_y), (x, target_y))
|
|
305
|
+
time.sleep(0.5)
|
|
306
|
+
subprocess.run([
|
|
307
|
+
'/usr/sbin/screencapture', '-x', f'-D{self.display_number}', str(after)
|
|
308
|
+
], check=True)
|
|
309
|
+
actual_y = (
|
|
310
|
+
self.detect_terminal_divider(after, search_range) + handle_offset
|
|
311
|
+
)
|
|
312
|
+
if abs(actual_y - target_y) > 8:
|
|
313
|
+
raise RuntimeError(
|
|
314
|
+
f'terminal divider verification failed: expected y={target_y}, '
|
|
315
|
+
f'found y={actual_y} (started at y={start_y})'
|
|
316
|
+
)
|
|
317
|
+
before.unlink(missing_ok=True)
|
|
318
|
+
after.unlink(missing_ok=True)
|
|
319
|
+
return start_y, actual_y
|
|
320
|
+
|
|
321
|
+
def prepare_default_vscode(self, profile=None):
|
|
322
|
+
selected = profile or DEFAULT_VSCODE_PROFILE
|
|
323
|
+
if self.logical_display_size() != tuple(selected['logical_display']):
|
|
324
|
+
raise RuntimeError('logical display does not match the accepted profile')
|
|
325
|
+
self.create_copy()
|
|
326
|
+
self.open_vscode()
|
|
327
|
+
self.wait_for_window()
|
|
328
|
+
self.assert_window_size(selected['minimum_window_size'])
|
|
329
|
+
auxiliary_panel_key_code = selected.get('auxiliary_panel_key_code')
|
|
330
|
+
if auxiliary_panel_key_code is not None:
|
|
331
|
+
self.hotkey(auxiliary_panel_key_code)
|
|
332
|
+
time.sleep(0.4)
|
|
333
|
+
self.open_terminal()
|
|
334
|
+
time.sleep(selected.get('terminal_open_delay', 1.5))
|
|
335
|
+
self.resize_terminal_panel(
|
|
336
|
+
selected['terminal_drag_x'], selected['terminal_target_y'],
|
|
337
|
+
selected['terminal_search_y']
|
|
338
|
+
)
|
|
339
|
+
time.sleep(0.4)
|
|
340
|
+
|
|
341
|
+
def quick_open(self, relative_path):
|
|
342
|
+
path = self._apple_string(relative_path)
|
|
343
|
+
self.targeted(f'''
|
|
344
|
+
keystroke "p" using command down
|
|
345
|
+
delay 0.25
|
|
346
|
+
set savedClipboard to missing value
|
|
347
|
+
try
|
|
348
|
+
set savedClipboard to the clipboard as record
|
|
349
|
+
end try
|
|
350
|
+
set the clipboard to "{path}"
|
|
351
|
+
keystroke "v" using command down
|
|
352
|
+
delay 0.2
|
|
353
|
+
key code 36
|
|
354
|
+
if savedClipboard is not missing value then set the clipboard to savedClipboard
|
|
355
|
+
''')
|
|
356
|
+
|
|
357
|
+
def hotkey(self, key_code, modifiers='command down'):
|
|
358
|
+
self.targeted(f'key code {key_code} using {modifiers}')
|
|
359
|
+
|
|
360
|
+
def type_text(self, text, character_delay=0.045):
|
|
361
|
+
safe = self._apple_string(text)
|
|
362
|
+
self.targeted(f'''
|
|
363
|
+
repeat with currentCharacter in characters of "{safe}"
|
|
364
|
+
keystroke currentCharacter
|
|
365
|
+
delay {character_delay}
|
|
366
|
+
end repeat
|
|
367
|
+
''')
|
|
368
|
+
|
|
369
|
+
def wait_for_files(self, relative_paths, timeout=60):
|
|
370
|
+
deadline = time.monotonic() + timeout
|
|
371
|
+
while time.monotonic() < deadline:
|
|
372
|
+
if all((self.project / path).exists() for path in relative_paths):
|
|
373
|
+
return
|
|
374
|
+
time.sleep(0.2)
|
|
375
|
+
missing = [path for path in relative_paths if not (self.project / path).exists()]
|
|
376
|
+
raise RuntimeError('timed out waiting for: ' + ', '.join(missing))
|
|
377
|
+
|
|
378
|
+
def mark(self, name, **details):
|
|
379
|
+
self.events.append({
|
|
380
|
+
'name': name,
|
|
381
|
+
'timestamp_ms': round(time.monotonic() * 1000),
|
|
382
|
+
**details
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
def start_capture(self):
|
|
386
|
+
self.output_video.parent.mkdir(parents=True, exist_ok=True)
|
|
387
|
+
self.capture = subprocess.Popen([
|
|
388
|
+
'/usr/sbin/screencapture', '-v', f'-D{self.display_number}', '-k', str(self.output_video)
|
|
389
|
+
])
|
|
390
|
+
self.mark('recording_started')
|
|
391
|
+
|
|
392
|
+
def stop_capture(self):
|
|
393
|
+
if self.capture is None:
|
|
394
|
+
return
|
|
395
|
+
if self.capture.poll() is None:
|
|
396
|
+
self.capture.send_signal(signal.SIGINT)
|
|
397
|
+
code = self.capture.wait(timeout=30)
|
|
398
|
+
self.capture = None
|
|
399
|
+
if code != 0 or not self.output_video.is_file():
|
|
400
|
+
raise RuntimeError(f'screen capture failed with exit code {code}')
|
|
401
|
+
self.mark('recording_stopped')
|
|
402
|
+
|
|
403
|
+
def write_events(self, metadata=None):
|
|
404
|
+
payload = {'schema_version': 1, **(metadata or {}), 'events': self.events}
|
|
405
|
+
self.output_events.write_text(json.dumps(payload, indent=2) + '\n')
|
|
406
|
+
|
|
407
|
+
def cleanup(self):
|
|
408
|
+
if self.capture is not None:
|
|
409
|
+
try:
|
|
410
|
+
self.stop_capture()
|
|
411
|
+
except Exception:
|
|
412
|
+
if self.capture is not None:
|
|
413
|
+
self.capture.kill()
|
|
414
|
+
self.capture.wait()
|
|
415
|
+
self.capture = None
|
|
416
|
+
if self.window_slug:
|
|
417
|
+
try:
|
|
418
|
+
self.targeted('keystroke "w" using {command down, shift down}')
|
|
419
|
+
except Exception:
|
|
420
|
+
pass
|
|
421
|
+
if self.project:
|
|
422
|
+
time.sleep(0.8)
|
|
423
|
+
shutil.rmtree(self.project, ignore_errors=True)
|
|
424
|
+
time.sleep(0.4)
|
|
425
|
+
shutil.rmtree(self.project, ignore_errors=True)
|
|
426
|
+
if self.temp_root:
|
|
427
|
+
shutil.rmtree(self.temp_root, ignore_errors=True)
|