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.
Files changed (107) hide show
  1. package/README.md +15 -1
  2. package/SKILL.md +22 -21
  3. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  4. package/package.json +1 -1
  5. package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
  6. package/platforms/chatgpt/tests/test_contract.py +7 -0
  7. package/pyproject.toml +6 -2
  8. package/session_daemon.py +91 -3
  9. package/src/browser_workspace.egg-info/PKG-INFO +2 -1
  10. package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
  11. package/src/browser_workspace.egg-info/entry_points.txt +2 -0
  12. package/src/browser_workspace.egg-info/requires.txt +1 -0
  13. package/src/browser_workspace.egg-info/top_level.txt +1 -1
  14. package/src/browser_workspace_app/__init__.py +1 -0
  15. package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
  16. package/src/browser_workspace_app/cli.py +236 -0
  17. package/src/browser_workspace_app/extension/manifest.json +26 -0
  18. package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
  19. package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
  20. package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
  21. package/src/browser_workspace_app/extension/workspace.html +8 -0
  22. package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
  23. package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
  24. package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
  25. package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
  26. package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
  27. package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
  28. package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
  29. package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
  30. package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
  31. package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
  32. package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
  33. package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
  34. package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
  35. package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
  36. package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
  37. package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
  38. package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
  39. package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
  40. package/src/browser_workspace_app/managed_chrome.py +244 -0
  41. package/src/browser_workspace_app/node/admin.mjs +44 -0
  42. package/src/browser_workspace_app/package.json +36 -0
  43. package/src/browser_workspace_app/platform_runner.py +204 -0
  44. package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
  45. package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
  46. package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
  47. package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
  48. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
  49. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
  50. package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
  51. package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
  52. package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
  53. package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
  54. package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
  55. package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
  56. package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
  57. package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
  58. package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
  59. package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
  60. package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
  61. package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
  62. package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
  63. package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
  64. package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
  65. package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
  66. package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
  67. package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
  68. package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
  69. package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
  70. package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
  71. package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
  72. package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
  73. package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
  74. package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
  75. package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
  76. package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
  77. package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
  78. package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
  79. package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
  80. package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
  81. package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
  82. package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
  83. package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
  84. package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
  85. package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
  86. package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
  87. package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
  88. package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
  89. package/src/browser_workspace_app/pyproject.toml +33 -0
  90. package/src/browser_workspace_app/session_client.py +107 -0
  91. package/src/browser_workspace_app/session_daemon.py +431 -0
  92. package/src/browser_harness/__init__.py +0 -2
  93. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  94. package/src/browser_harness/_ipc.py +0 -201
  95. package/src/browser_harness/admin.py +0 -1588
  96. package/src/browser_harness/auth.py +0 -546
  97. package/src/browser_harness/daemon.py +0 -894
  98. package/src/browser_harness/helpers.py +0 -668
  99. package/src/browser_harness/macos.py +0 -131
  100. package/src/browser_harness/mcp_cli.py +0 -16
  101. package/src/browser_harness/paths.py +0 -49
  102. package/src/browser_harness/recorder.py +0 -343
  103. package/src/browser_harness/run.py +0 -413
  104. package/src/browser_harness/telemetry.py +0 -308
  105. package/src/browser_harness/video-template.html +0 -970
  106. package/src/browser_harness/video.py +0 -749
  107. 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