browser-workspace 0.4.2 → 0.4.5
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 +15 -1
- package/SKILL.md +22 -21
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/package.json +1 -1
- package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
- package/platforms/chatgpt/tests/test_contract.py +7 -0
- package/pyproject.toml +6 -2
- package/session_daemon.py +91 -3
- package/src/browser_workspace.egg-info/PKG-INFO +2 -1
- package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
- package/src/browser_workspace.egg-info/entry_points.txt +2 -0
- package/src/browser_workspace.egg-info/requires.txt +1 -0
- package/src/browser_workspace.egg-info/top_level.txt +1 -1
- package/src/browser_workspace_app/__init__.py +1 -0
- package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
- package/src/browser_workspace_app/cli.py +236 -0
- package/src/browser_workspace_app/extension/manifest.json +26 -0
- package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
- package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
- package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
- package/src/browser_workspace_app/extension/workspace.html +8 -0
- package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
- package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
- package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
- package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
- package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
- package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
- package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
- package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
- package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
- package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
- package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
- package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
- package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
- package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
- package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
- package/src/browser_workspace_app/managed_chrome.py +244 -0
- package/src/browser_workspace_app/node/admin.mjs +44 -0
- package/src/browser_workspace_app/package.json +36 -0
- package/src/browser_workspace_app/platform_runner.py +204 -0
- package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
- package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
- package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
- package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
- package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
- package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
- package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
- package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
- package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
- package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
- package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
- package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
- package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
- package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
- package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
- package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
- package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
- package/src/browser_workspace_app/pyproject.toml +33 -0
- package/src/browser_workspace_app/session_client.py +107 -0
- package/src/browser_workspace_app/session_daemon.py +431 -0
- package/src/browser_harness/__init__.py +0 -2
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +0 -201
- package/src/browser_harness/admin.py +0 -1588
- package/src/browser_harness/auth.py +0 -546
- package/src/browser_harness/daemon.py +0 -894
- package/src/browser_harness/helpers.py +0 -668
- package/src/browser_harness/macos.py +0 -131
- package/src/browser_harness/mcp_cli.py +0 -16
- package/src/browser_harness/paths.py +0 -49
- package/src/browser_harness/recorder.py +0 -343
- package/src/browser_harness/run.py +0 -413
- package/src/browser_harness/telemetry.py +0 -308
- package/src/browser_harness/video-template.html +0 -970
- package/src/browser_harness/video.py +0 -749
- package/src/browser_harness/video_render.py +0 -524
|
@@ -1,524 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Prepare compact renderer review sheets, then export and verify an MP4."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import contextlib
|
|
7
|
-
import json
|
|
8
|
-
import math
|
|
9
|
-
import os
|
|
10
|
-
import shutil
|
|
11
|
-
import subprocess
|
|
12
|
-
import sys
|
|
13
|
-
import threading
|
|
14
|
-
import time
|
|
15
|
-
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
|
|
18
|
-
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
19
|
-
|
|
20
|
-
from . import video
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
TEMPLATE = Path(__file__).with_name("video-template.html")
|
|
24
|
-
MARKER = "__BH_VIDEO_RESULT__="
|
|
25
|
-
REVIEW_ARTIFACTS = {
|
|
26
|
-
"composition.js",
|
|
27
|
-
"recording-summary.json",
|
|
28
|
-
"edit-brief.json",
|
|
29
|
-
video.SOURCE_MANIFEST,
|
|
30
|
-
"video.html",
|
|
31
|
-
}
|
|
32
|
-
OBSOLETE_REVIEW_FILES = {
|
|
33
|
-
"privacy-contact-sheet.jpg",
|
|
34
|
-
"renderer-normal-contact-sheet.jpg",
|
|
35
|
-
"renderer-reduced-contact-sheet.jpg",
|
|
36
|
-
"renderer-click-contact-sheet.jpg",
|
|
37
|
-
"video-audit.json",
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def load_composition(recording: Path) -> dict:
|
|
42
|
-
return video.load_composition(recording / "composition.js")
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def compile_recording(recording: Path, write: bool) -> dict:
|
|
46
|
-
video.verify_source_manifest(recording)
|
|
47
|
-
summary = video.load_json(recording / "recording-summary.json")
|
|
48
|
-
brief = video.load_json(recording / "edit-brief.json")
|
|
49
|
-
composition = video.compile_brief(
|
|
50
|
-
summary, brief, video.HOUSE_STYLE, video.load_revealed_text(recording / "events.jsonl")
|
|
51
|
-
)
|
|
52
|
-
if write:
|
|
53
|
-
video.write_composition(recording / "composition.js", composition)
|
|
54
|
-
return composition
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def review_samples(comp: dict) -> list[dict]:
|
|
58
|
-
"""One stable state per beat, plus every explanation reveal."""
|
|
59
|
-
samples = []
|
|
60
|
-
start = 0.0
|
|
61
|
-
for index, beat in enumerate(comp.get("beats") or [], 1):
|
|
62
|
-
duration = float(beat.get("dur") or 0)
|
|
63
|
-
if beat.get("kind") == "explanation" and beat.get("points"):
|
|
64
|
-
points = beat["points"]
|
|
65
|
-
first, final_hold = 1.1, 3.0
|
|
66
|
-
span = max(0.0, duration - first - final_hold)
|
|
67
|
-
gap = span / max(1, len(points) - 1)
|
|
68
|
-
for point_index, point in enumerate(points, 1):
|
|
69
|
-
local = min(max(0.05, duration - 0.05), first + (point_index - 1) * gap + 0.2)
|
|
70
|
-
samples.append({
|
|
71
|
-
"time": round(start + local, 3),
|
|
72
|
-
"label": f"beat {index} · {point.get('label') or point_index}",
|
|
73
|
-
})
|
|
74
|
-
else:
|
|
75
|
-
local = min(max(0.05, duration - 0.05), 1.0 if beat.get("card") else max(0.12, min(0.5, duration / 2)))
|
|
76
|
-
samples.append({"time": round(start + local, 3), "label": f"beat {index}"})
|
|
77
|
-
start += duration
|
|
78
|
-
return samples
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
class _QuietHandler(SimpleHTTPRequestHandler):
|
|
82
|
-
def log_message(self, _format, *_args):
|
|
83
|
-
pass
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
@contextlib.contextmanager
|
|
87
|
-
def serve(recording: Path):
|
|
88
|
-
handler = lambda *args, **kwargs: _QuietHandler( # noqa: E731
|
|
89
|
-
*args, directory=str(recording), **kwargs
|
|
90
|
-
)
|
|
91
|
-
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
92
|
-
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
93
|
-
thread.start()
|
|
94
|
-
try:
|
|
95
|
-
yield f"http://127.0.0.1:{server.server_port}/video.html"
|
|
96
|
-
finally:
|
|
97
|
-
server.shutdown()
|
|
98
|
-
server.server_close()
|
|
99
|
-
thread.join(timeout=2)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _harness_command() -> list[str]:
|
|
103
|
-
return [sys.executable, "-m", "browser_harness.run"]
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def run_harness(code: str, timeout: float = 60) -> dict:
|
|
107
|
-
env = {**os.environ, "BH_RECORD": "0"}
|
|
108
|
-
proc = subprocess.run(
|
|
109
|
-
_harness_command(),
|
|
110
|
-
input=code,
|
|
111
|
-
text=True,
|
|
112
|
-
capture_output=True,
|
|
113
|
-
env=env,
|
|
114
|
-
timeout=timeout,
|
|
115
|
-
check=False,
|
|
116
|
-
)
|
|
117
|
-
if proc.returncode:
|
|
118
|
-
detail = (proc.stderr or proc.stdout).strip()
|
|
119
|
-
raise RuntimeError(f"browser review failed: {detail}")
|
|
120
|
-
for line in reversed(proc.stdout.splitlines()):
|
|
121
|
-
if line.startswith(MARKER):
|
|
122
|
-
return json.loads(line[len(MARKER):])
|
|
123
|
-
raise RuntimeError(f"browser review returned no result: {proc.stdout[-1000:]}")
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def _review_browser(recording: Path, url: str, samples: list[dict]) -> dict:
|
|
127
|
-
review_dir = recording / ".renderer-review"
|
|
128
|
-
payload = {
|
|
129
|
-
"url": url,
|
|
130
|
-
"samples": samples,
|
|
131
|
-
"reviewDir": str(review_dir),
|
|
132
|
-
"marker": MARKER,
|
|
133
|
-
}
|
|
134
|
-
code = f"""
|
|
135
|
-
import json, os, time
|
|
136
|
-
cfg = json.loads({json.dumps(json.dumps(payload))})
|
|
137
|
-
os.makedirs(cfg['reviewDir'], exist_ok=True)
|
|
138
|
-
|
|
139
|
-
def ready():
|
|
140
|
-
for _ in range(100):
|
|
141
|
-
if js('window.videoReady && window.videoReady()'):
|
|
142
|
-
return
|
|
143
|
-
time.sleep(0.05)
|
|
144
|
-
raise RuntimeError('video assets did not become ready')
|
|
145
|
-
|
|
146
|
-
def inspect_mode(name, reduced):
|
|
147
|
-
features = [{{'name':'prefers-reduced-motion','value':'reduce'}}] if reduced else []
|
|
148
|
-
cdp('Emulation.setEmulatedMedia', media='', features=features)
|
|
149
|
-
cdp('Page.reload', ignoreCache=True)
|
|
150
|
-
wait_for_load()
|
|
151
|
-
ready()
|
|
152
|
-
preflight = js('window.videoPreflight()')
|
|
153
|
-
clicks = js('window.clickVisibility()')
|
|
154
|
-
captures = []
|
|
155
|
-
for index, sample in enumerate(cfg['samples'], 1):
|
|
156
|
-
js(f"window.seek({{sample['time']}})")
|
|
157
|
-
path = f"{{cfg['reviewDir']}}/{{name}}-beat-{{index:02d}}.png"
|
|
158
|
-
capture_screenshot(path)
|
|
159
|
-
captures.append({{'path': path, 'time': sample['time'], 'label': sample['label']}})
|
|
160
|
-
click_captures = []
|
|
161
|
-
for index, click in enumerate(clicks, 1):
|
|
162
|
-
for state, key in (('click', 'time'), ('result', 'resultTime')):
|
|
163
|
-
js(f"window.seek({{click[key]}})")
|
|
164
|
-
path = f"{{cfg['reviewDir']}}/{{name}}-click-{{index:02d}}-{{state}}.png"
|
|
165
|
-
capture_screenshot(path)
|
|
166
|
-
click_captures.append({{
|
|
167
|
-
'path': path, 'time': click[key],
|
|
168
|
-
'label': f"beat {{click['beat']}} · {{state}}",
|
|
169
|
-
}})
|
|
170
|
-
return {{'preflight': preflight, 'clicks': clicks, 'captures': captures,
|
|
171
|
-
'clickCaptures': click_captures}}
|
|
172
|
-
|
|
173
|
-
try:
|
|
174
|
-
previous = current_tab()['targetId']
|
|
175
|
-
except Exception:
|
|
176
|
-
previous = None
|
|
177
|
-
target = None
|
|
178
|
-
try:
|
|
179
|
-
target = new_tab()
|
|
180
|
-
goto_url(cfg['url'])
|
|
181
|
-
wait_for_load()
|
|
182
|
-
ready()
|
|
183
|
-
normal = inspect_mode('normal', False)
|
|
184
|
-
reduced = inspect_mode('reduced', True)
|
|
185
|
-
print(cfg['marker'] + json.dumps({{'normal': normal, 'reduced': reduced}}))
|
|
186
|
-
finally:
|
|
187
|
-
try:
|
|
188
|
-
cdp('Emulation.setEmulatedMedia', media='', features=[])
|
|
189
|
-
except Exception:
|
|
190
|
-
pass
|
|
191
|
-
try:
|
|
192
|
-
switch_tab(previous) if previous else new_tab()
|
|
193
|
-
except Exception:
|
|
194
|
-
try:
|
|
195
|
-
new_tab()
|
|
196
|
-
except Exception:
|
|
197
|
-
pass
|
|
198
|
-
if target is not None:
|
|
199
|
-
try:
|
|
200
|
-
close_tab(target)
|
|
201
|
-
except Exception:
|
|
202
|
-
pass
|
|
203
|
-
"""
|
|
204
|
-
return run_harness(code, timeout=max(60, len(samples) * 3))
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def contact_sheet(captures: list[dict], output: Path, title: str) -> None:
|
|
208
|
-
cols, tile_w, tile_h, label_h, gap, banner = 4, 400, 225, 34, 8, 42
|
|
209
|
-
rows = max(1, math.ceil(len(captures) / cols))
|
|
210
|
-
sheet = Image.new(
|
|
211
|
-
"RGB",
|
|
212
|
-
(cols * tile_w + (cols + 1) * gap, banner + rows * (tile_h + label_h + gap) + gap),
|
|
213
|
-
"#171a20",
|
|
214
|
-
)
|
|
215
|
-
draw, font = ImageDraw.Draw(sheet), ImageFont.load_default()
|
|
216
|
-
draw.text((gap, 15), title, fill="#ffffff", font=font)
|
|
217
|
-
for index, capture in enumerate(captures):
|
|
218
|
-
image = Image.open(capture["path"]).convert("RGB")
|
|
219
|
-
preview = ImageOps.contain(image, (tile_w, tile_h), Image.Resampling.LANCZOS)
|
|
220
|
-
col, row = index % cols, index // cols
|
|
221
|
-
x, y = gap + col * (tile_w + gap), banner + gap + row * (tile_h + label_h + gap)
|
|
222
|
-
sheet.paste(preview, (x + (tile_w - preview.width) // 2, y))
|
|
223
|
-
label = f"{capture['label']} {float(capture['time']):.2f}s"
|
|
224
|
-
draw.text((x, y + tile_h + 9), label, fill="#d7dbe3", font=font)
|
|
225
|
-
sheet.save(output, quality=91)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def masked_frame(recording: Path, comp: dict, frame: str) -> Image.Image:
|
|
229
|
-
viewport = comp["viewport"]
|
|
230
|
-
vw, vh = float(viewport["w"]), float(viewport["h"])
|
|
231
|
-
redactions = comp.get("redact") or {}
|
|
232
|
-
privacy = comp.get("privacy") or {}
|
|
233
|
-
mask = privacy.get("mask") or {}
|
|
234
|
-
pad = float(privacy.get("pad") or 8)
|
|
235
|
-
image = Image.open(recording / frame).convert("RGB")
|
|
236
|
-
sx, sy = image.width / vw, image.height / vh
|
|
237
|
-
draw = ImageDraw.Draw(image)
|
|
238
|
-
for rectangle in redactions.get(frame, []):
|
|
239
|
-
rect_pad = float(rectangle.get("pad", pad))
|
|
240
|
-
x0 = max(0, (float(rectangle["x"]) - rect_pad) * sx)
|
|
241
|
-
y0 = max(0, (float(rectangle["y"]) - rect_pad) * sy)
|
|
242
|
-
x1 = min(image.width, (float(rectangle["x"]) + float(rectangle["w"]) + rect_pad) * sx)
|
|
243
|
-
y1 = min(image.height, (float(rectangle["y"]) + float(rectangle["h"]) + rect_pad) * sy)
|
|
244
|
-
fill = rectangle.get("fill", mask.get("fill", "#f2f4f7"))
|
|
245
|
-
stroke = rectangle.get("stroke", mask.get("stroke", "#e2e7ec"))
|
|
246
|
-
radius = float(rectangle.get("radius", mask.get("radius", 7))) * min(sx, sy)
|
|
247
|
-
draw.rounded_rectangle(
|
|
248
|
-
(x0, y0, x1, y1), radius=radius, fill=fill,
|
|
249
|
-
outline=stroke or None, width=max(1, round(min(sx, sy))),
|
|
250
|
-
)
|
|
251
|
-
return image
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def privacy_review(recording: Path, comp: dict) -> tuple[Path, list[dict]]:
|
|
255
|
-
review_dir = recording / ".privacy-review"
|
|
256
|
-
review_dir.mkdir(exist_ok=True)
|
|
257
|
-
for stale in review_dir.glob("*.jpg"):
|
|
258
|
-
stale.unlink()
|
|
259
|
-
captures = []
|
|
260
|
-
redactions = comp.get("redact") or {}
|
|
261
|
-
for frame in video.used_frames(comp):
|
|
262
|
-
source = recording / frame
|
|
263
|
-
if not source.is_file():
|
|
264
|
-
raise RuntimeError(f"missing frame: {frame}")
|
|
265
|
-
output = review_dir / frame
|
|
266
|
-
masked_frame(recording, comp, frame).save(output, quality=94)
|
|
267
|
-
captures.append(
|
|
268
|
-
{
|
|
269
|
-
"path": str(output),
|
|
270
|
-
"time": 0,
|
|
271
|
-
"label": f"privacy · {frame} · masks:{len(redactions.get(frame, []))}",
|
|
272
|
-
}
|
|
273
|
-
)
|
|
274
|
-
return review_dir, captures
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def review(recording: Path) -> int:
|
|
278
|
-
started = time.monotonic()
|
|
279
|
-
for name in OBSOLETE_REVIEW_FILES:
|
|
280
|
-
(recording / name).unlink(missing_ok=True)
|
|
281
|
-
comp = compile_recording(recording, write=True)
|
|
282
|
-
shutil.copy2(TEMPLATE, recording / "video.html")
|
|
283
|
-
privacy_dir, privacy_captures = privacy_review(recording, comp)
|
|
284
|
-
samples = review_samples(comp)
|
|
285
|
-
review_dir = recording / ".renderer-review"
|
|
286
|
-
review_dir.mkdir(exist_ok=True)
|
|
287
|
-
for stale in review_dir.glob("*.png"):
|
|
288
|
-
stale.unlink()
|
|
289
|
-
with serve(recording) as url:
|
|
290
|
-
result = _review_browser(recording, url, samples)
|
|
291
|
-
|
|
292
|
-
errors = []
|
|
293
|
-
warnings = []
|
|
294
|
-
all_captures = list(privacy_captures)
|
|
295
|
-
for mode in ("normal", "reduced"):
|
|
296
|
-
errors.extend(f"{mode}: {error}" for error in result[mode]["preflight"].get("errors", []))
|
|
297
|
-
warnings.extend(
|
|
298
|
-
f"{mode}: {warning}"
|
|
299
|
-
for warning in result[mode]["preflight"].get("warnings", [])
|
|
300
|
-
)
|
|
301
|
-
errors.extend(
|
|
302
|
-
f"{mode}: beat {click['beat']} click is outside the safe viewport"
|
|
303
|
-
for click in result[mode]["clicks"] if not click.get("visible")
|
|
304
|
-
)
|
|
305
|
-
for capture in result[mode]["captures"]:
|
|
306
|
-
all_captures.append({**capture, "label": f"{mode} · {capture['label']}"})
|
|
307
|
-
for capture in result[mode]["clickCaptures"]:
|
|
308
|
-
all_captures.append({**capture, "label": f"{mode} · {capture['label']}"})
|
|
309
|
-
|
|
310
|
-
sheet = recording / "video-review-contact-sheet.jpg"
|
|
311
|
-
contact_sheet(all_captures, sheet, "PRIVACY · EVERY BEAT · EXACT CLICK + RESULT")
|
|
312
|
-
|
|
313
|
-
report = {
|
|
314
|
-
"errors": errors,
|
|
315
|
-
"warnings": warnings,
|
|
316
|
-
"duration": round(sum(float(beat.get("dur") or 0) for beat in comp.get("beats") or []), 3),
|
|
317
|
-
"artifactHashes": {
|
|
318
|
-
name: video.file_hash(recording / name) for name in sorted(REVIEW_ARTIFACTS)
|
|
319
|
-
},
|
|
320
|
-
"normal": result["normal"],
|
|
321
|
-
"reduced": result["reduced"],
|
|
322
|
-
"contactSheet": str(sheet),
|
|
323
|
-
"privacyReviewDir": str(privacy_dir),
|
|
324
|
-
"elapsedSeconds": round(time.monotonic() - started, 3),
|
|
325
|
-
}
|
|
326
|
-
report_path = recording / "renderer-review.json"
|
|
327
|
-
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
328
|
-
print(f"review sheet: {sheet}")
|
|
329
|
-
print(f"full-resolution privacy review: {privacy_dir}")
|
|
330
|
-
print(f"renderer review: {len(errors)} error(s) in {report['elapsedSeconds']:.1f}s")
|
|
331
|
-
return 1 if errors else 0
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def _start_export(recording: Path, url: str, webm: Path) -> dict:
|
|
335
|
-
webm.parent.mkdir(parents=True, exist_ok=True)
|
|
336
|
-
payload = {"url": url, "downloadPath": str(webm.parent.resolve()), "filename": webm.name, "marker": MARKER}
|
|
337
|
-
filename_js = json.dumps(webm.name)
|
|
338
|
-
code = f"""
|
|
339
|
-
import json, time
|
|
340
|
-
cfg = json.loads({json.dumps(json.dumps(payload))})
|
|
341
|
-
try:
|
|
342
|
-
previous = current_tab()['targetId']
|
|
343
|
-
except Exception:
|
|
344
|
-
previous = None
|
|
345
|
-
target = new_tab()
|
|
346
|
-
goto_url(cfg['url'])
|
|
347
|
-
wait_for_load()
|
|
348
|
-
for _ in range(100):
|
|
349
|
-
if js('window.videoReady && window.videoReady()'):
|
|
350
|
-
break
|
|
351
|
-
time.sleep(0.05)
|
|
352
|
-
else:
|
|
353
|
-
raise RuntimeError('video assets did not become ready')
|
|
354
|
-
preflight = js('window.videoPreflight()')
|
|
355
|
-
clicks = js('window.clickVisibility()')
|
|
356
|
-
cdp('Browser.setDownloadBehavior', behavior='allow', downloadPath=cfg['downloadPath'], eventsEnabled=True)
|
|
357
|
-
cdp('Page.bringToFront')
|
|
358
|
-
started = js('''(() => {{
|
|
359
|
-
window.__exported = null; window.__exportError = null;
|
|
360
|
-
window.exportVideo({filename_js})
|
|
361
|
-
.catch(error => window.__exportError = String(error));
|
|
362
|
-
return true;
|
|
363
|
-
}})()''')
|
|
364
|
-
print(cfg['marker'] + json.dumps({{'target': target, 'previous': previous, 'preflight': preflight, 'clicks': clicks, 'started': started}}))
|
|
365
|
-
"""
|
|
366
|
-
return run_harness(code, timeout=30)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def _close_editor(url: str, previous: str | None = None) -> bool:
|
|
370
|
-
payload = {"url": url, "previous": previous, "marker": MARKER}
|
|
371
|
-
code = f"""
|
|
372
|
-
import json
|
|
373
|
-
cfg = json.loads({json.dumps(json.dumps(payload))})
|
|
374
|
-
downloads_reset = True
|
|
375
|
-
try:
|
|
376
|
-
cdp('Browser.setDownloadBehavior', behavior='default', eventsEnabled=False)
|
|
377
|
-
except Exception:
|
|
378
|
-
downloads_reset = False
|
|
379
|
-
targets = [tab for tab in list_tabs() if tab.get('url') == cfg['url']]
|
|
380
|
-
try:
|
|
381
|
-
switch_tab(cfg['previous']) if cfg.get('previous') else new_tab()
|
|
382
|
-
except Exception:
|
|
383
|
-
new_tab()
|
|
384
|
-
for tab in targets:
|
|
385
|
-
try:
|
|
386
|
-
close_tab(tab)
|
|
387
|
-
except Exception:
|
|
388
|
-
pass
|
|
389
|
-
print(cfg['marker'] + json.dumps({{'closed': True, 'downloadsReset': downloads_reset}}))
|
|
390
|
-
"""
|
|
391
|
-
try:
|
|
392
|
-
result = run_harness(code, timeout=15)
|
|
393
|
-
except Exception:
|
|
394
|
-
return False
|
|
395
|
-
return result.get("downloadsReset") is True
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
def _run(command: list[str], cwd: Path, timeout: float = 120) -> subprocess.CompletedProcess:
|
|
399
|
-
proc = subprocess.run(command, cwd=cwd, text=True, capture_output=True, timeout=timeout, check=False)
|
|
400
|
-
if proc.returncode:
|
|
401
|
-
raise RuntimeError((proc.stderr or proc.stdout).strip())
|
|
402
|
-
return proc
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def _probe(path: Path) -> dict:
|
|
406
|
-
proc = _run([
|
|
407
|
-
"ffprobe", "-v", "error", "-show_entries",
|
|
408
|
-
"format=duration,size:stream=codec_name,width,height,pix_fmt,r_frame_rate",
|
|
409
|
-
"-of", "json", str(path),
|
|
410
|
-
], path.parent)
|
|
411
|
-
return json.loads(proc.stdout)
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
def export(recording: Path, output_name: str, reviewed: bool) -> int:
|
|
415
|
-
if not reviewed:
|
|
416
|
-
raise RuntimeError("inspect the review sheet and full-resolution privacy frames, then rerun with --reviewed")
|
|
417
|
-
if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
|
|
418
|
-
raise RuntimeError("ffmpeg and ffprobe are required")
|
|
419
|
-
review_path = recording / "renderer-review.json"
|
|
420
|
-
if not review_path.is_file():
|
|
421
|
-
raise RuntimeError("run browser-harness video review first")
|
|
422
|
-
review_report = json.loads(review_path.read_text(encoding="utf-8"))
|
|
423
|
-
if review_report.get("errors"):
|
|
424
|
-
raise RuntimeError("renderer review has blocking errors")
|
|
425
|
-
video.verify_source_manifest(recording)
|
|
426
|
-
comp = load_composition(recording)
|
|
427
|
-
expected_comp = compile_recording(recording, write=False)
|
|
428
|
-
if comp != expected_comp:
|
|
429
|
-
raise RuntimeError("composition.js is not the current compiled brief; rerun review")
|
|
430
|
-
artifact_hashes = review_report.get("artifactHashes") or {}
|
|
431
|
-
if set(artifact_hashes) != REVIEW_ARTIFACTS:
|
|
432
|
-
raise RuntimeError("renderer review lacks content hashes; rerun it")
|
|
433
|
-
for name, expected_hash in artifact_hashes.items():
|
|
434
|
-
path = recording / name
|
|
435
|
-
if not path.is_file() or video.file_hash(path) != expected_hash:
|
|
436
|
-
raise RuntimeError(f"{name} changed after review; rerun it")
|
|
437
|
-
renderer = recording / "video.html"
|
|
438
|
-
if video.file_hash(renderer) != video.file_hash(TEMPLATE):
|
|
439
|
-
raise RuntimeError("renderer is not the current shared template; rerun review")
|
|
440
|
-
|
|
441
|
-
output = Path(output_name)
|
|
442
|
-
if not output.is_absolute():
|
|
443
|
-
output = recording / output
|
|
444
|
-
if output.suffix.lower() != ".mp4":
|
|
445
|
-
raise RuntimeError("--output must end in .mp4")
|
|
446
|
-
webm = output.with_suffix(".webm")
|
|
447
|
-
for path in (webm, output):
|
|
448
|
-
if path.exists() or path.with_name(path.name + ".crdownload").exists():
|
|
449
|
-
raise RuntimeError(f"refusing to overwrite {path}")
|
|
450
|
-
|
|
451
|
-
expected = sum(float(beat.get("dur") or 0) for beat in comp.get("beats") or [])
|
|
452
|
-
started = time.monotonic()
|
|
453
|
-
with serve(recording) as url:
|
|
454
|
-
browser = None
|
|
455
|
-
try:
|
|
456
|
-
browser = _start_export(recording, url, webm)
|
|
457
|
-
if browser["preflight"].get("errors"):
|
|
458
|
-
raise RuntimeError(
|
|
459
|
-
"export preflight failed: " + "; ".join(browser["preflight"]["errors"])
|
|
460
|
-
)
|
|
461
|
-
if any(not click.get("visible") for click in browser["clicks"]):
|
|
462
|
-
raise RuntimeError("export click visibility failed")
|
|
463
|
-
deadline = time.monotonic() + expected + 30
|
|
464
|
-
partial = webm.with_name(webm.name + ".crdownload")
|
|
465
|
-
while time.monotonic() < deadline:
|
|
466
|
-
if webm.is_file() and not partial.exists() and webm.stat().st_size:
|
|
467
|
-
size = webm.stat().st_size
|
|
468
|
-
time.sleep(0.25)
|
|
469
|
-
if webm.stat().st_size == size:
|
|
470
|
-
break
|
|
471
|
-
time.sleep(0.25)
|
|
472
|
-
else:
|
|
473
|
-
raise RuntimeError(f"timed out waiting for {webm}")
|
|
474
|
-
finally:
|
|
475
|
-
if not _close_editor(url, (browser or {}).get("previous")):
|
|
476
|
-
raise RuntimeError(
|
|
477
|
-
"could not restore Chrome download behavior; restart Chrome"
|
|
478
|
-
)
|
|
479
|
-
capture_seconds = time.monotonic() - started
|
|
480
|
-
|
|
481
|
-
conversion_started = time.monotonic()
|
|
482
|
-
_probe(webm)
|
|
483
|
-
_run([
|
|
484
|
-
"ffmpeg", "-v", "error", "-i", str(webm), "-c:v", "libx264",
|
|
485
|
-
"-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(output),
|
|
486
|
-
], recording)
|
|
487
|
-
conversion_seconds = time.monotonic() - conversion_started
|
|
488
|
-
|
|
489
|
-
verify_started = time.monotonic()
|
|
490
|
-
probe = _probe(output)
|
|
491
|
-
actual = float(probe["format"]["duration"])
|
|
492
|
-
if abs(actual - expected) > max(1.0, expected * 0.08):
|
|
493
|
-
raise RuntimeError(f"export duration {actual:.2f}s does not match composition {expected:.2f}s")
|
|
494
|
-
_run(["ffmpeg", "-v", "error", "-err_detect", "explode", "-i", str(output), "-f", "null", "-"], recording)
|
|
495
|
-
|
|
496
|
-
review_dir = recording / ".renderer-review"
|
|
497
|
-
final_captures = []
|
|
498
|
-
for index, at in enumerate((min(1.0, expected / 4), expected / 2, max(0.0, expected - 0.8)), 1):
|
|
499
|
-
path = review_dir / f"final-{index:02d}.jpg"
|
|
500
|
-
_run(["ffmpeg", "-v", "error", "-y", "-ss", f"{at:.3f}", "-i", str(output), "-frames:v", "1", str(path)], recording)
|
|
501
|
-
final_captures.append({"path": str(path), "time": at, "label": ("intro", "middle", "outcome")[index - 1]})
|
|
502
|
-
final_sheet = recording / "renderer-final-contact-sheet.jpg"
|
|
503
|
-
contact_sheet(final_captures, final_sheet, "FINAL MP4 SAMPLE")
|
|
504
|
-
verify_seconds = time.monotonic() - verify_started
|
|
505
|
-
|
|
506
|
-
report = {
|
|
507
|
-
"output": str(output),
|
|
508
|
-
"webm": str(webm),
|
|
509
|
-
"expectedDuration": round(expected, 3),
|
|
510
|
-
"actualDuration": actual,
|
|
511
|
-
"captureSeconds": round(capture_seconds, 3),
|
|
512
|
-
"conversionSeconds": round(conversion_seconds, 3),
|
|
513
|
-
"verificationSeconds": round(verify_seconds, 3),
|
|
514
|
-
"elapsedSeconds": round(time.monotonic() - started, 3),
|
|
515
|
-
"sha256": video.file_hash(output),
|
|
516
|
-
"probe": probe,
|
|
517
|
-
"finalContactSheet": str(final_sheet),
|
|
518
|
-
}
|
|
519
|
-
report_path = recording / "video-export.json"
|
|
520
|
-
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
521
|
-
print(f"video: {output}")
|
|
522
|
-
print(f"final review: {final_sheet}")
|
|
523
|
-
print(f"verified {actual:.2f}s MP4 in {report['elapsedSeconds']:.1f}s")
|
|
524
|
-
return 0
|