browser-workspace 0.4.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.
Files changed (108) hide show
  1. package/PRIVACY.md +21 -0
  2. package/README.md +138 -0
  3. package/SKILL.md +301 -0
  4. package/agent-workspace/agent_helpers.py +718 -0
  5. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  6. package/bin/browser-workspace +75 -0
  7. package/extension/manifest.json +26 -0
  8. package/extension/service-worker.mjs +126 -0
  9. package/extension/workspace-manager.mjs +680 -0
  10. package/extension/workspace-page.mjs +7 -0
  11. package/extension/workspace.html +8 -0
  12. package/interaction-skills/connection.md +52 -0
  13. package/interaction-skills/cookies.md +3 -0
  14. package/interaction-skills/cross-origin-iframes.md +3 -0
  15. package/interaction-skills/dialogs.md +64 -0
  16. package/interaction-skills/downloads.md +3 -0
  17. package/interaction-skills/drag-and-drop.md +3 -0
  18. package/interaction-skills/dropdowns.md +3 -0
  19. package/interaction-skills/iframes.md +3 -0
  20. package/interaction-skills/network-requests.md +3 -0
  21. package/interaction-skills/print-as-pdf.md +3 -0
  22. package/interaction-skills/profile-sync.md +90 -0
  23. package/interaction-skills/screenshots.md +17 -0
  24. package/interaction-skills/scrolling.md +22 -0
  25. package/interaction-skills/shadow-dom.md +3 -0
  26. package/interaction-skills/tabs.md +73 -0
  27. package/interaction-skills/uploads.md +1 -0
  28. package/interaction-skills/viewport.md +16 -0
  29. package/licenses/browser-harness-MIT.txt +21 -0
  30. package/managed_chrome.py +244 -0
  31. package/node/admin.mjs +44 -0
  32. package/package.json +36 -0
  33. package/platform_runner.py +187 -0
  34. package/platforms/chatgpt/README.md +60 -0
  35. package/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
  36. package/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
  37. package/platforms/chatgpt/actions/_mcp_app.py +80 -0
  38. package/platforms/chatgpt/actions/_new_thread.py +85 -0
  39. package/platforms/chatgpt/actions/_new_turn.py +33 -0
  40. package/platforms/chatgpt/actions/_project_setup.py +353 -0
  41. package/platforms/chatgpt/actions/_readiness.py +52 -0
  42. package/platforms/chatgpt/actions/_submit_thread.py +129 -0
  43. package/platforms/chatgpt/actions/_temporary_submit.py +571 -0
  44. package/platforms/chatgpt/actions/_thread_turn.py +362 -0
  45. package/platforms/chatgpt/manifest.yaml +34 -0
  46. package/platforms/chatgpt/tests/test_contract.py +176 -0
  47. package/platforms/chrome-web-store/README.md +49 -0
  48. package/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
  49. package/platforms/chrome-web-store/flows/check-status.md +7 -0
  50. package/platforms/chrome-web-store/flows/open-item.md +10 -0
  51. package/platforms/chrome-web-store/flows/submit-review.md +10 -0
  52. package/platforms/chrome-web-store/flows/update-listing.md +18 -0
  53. package/platforms/chrome-web-store/flows/upload-package.md +11 -0
  54. package/platforms/chrome-web-store/flows/verify-published.md +9 -0
  55. package/platforms/chrome-web-store/manifest.yaml +33 -0
  56. package/platforms/chrome-web-store/tests/test_contract.py +83 -0
  57. package/platforms/microsoft-teams/README.md +32 -0
  58. package/platforms/microsoft-teams/actions/_meeting.py +210 -0
  59. package/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
  60. package/platforms/microsoft-teams/manifest.yaml +25 -0
  61. package/platforms/microsoft-teams/tests/test_contract.py +24 -0
  62. package/platforms/tiktok/README.md +10 -0
  63. package/platforms/tiktok/actions/_post.py +180 -0
  64. package/platforms/tiktok/manifest.yaml +99 -0
  65. package/platforms/wechat-channels/README.md +10 -0
  66. package/platforms/wechat-channels/actions/_common.py +163 -0
  67. package/platforms/wechat-channels/actions/_manage.py +130 -0
  68. package/platforms/wechat-channels/actions/_post.py +232 -0
  69. package/platforms/wechat-channels/manifest.yaml +118 -0
  70. package/platforms/wechat-channels/tests/test_contract.py +36 -0
  71. package/platforms/xhs/README.md +10 -0
  72. package/platforms/xhs/actions/_manage.py +355 -0
  73. package/platforms/xhs/actions/_post.py +192 -0
  74. package/platforms/xhs/manifest.yaml +98 -0
  75. package/platforms/youtube/README.md +10 -0
  76. package/platforms/youtube/actions/_post.py +100 -0
  77. package/platforms/youtube/manifest.yaml +94 -0
  78. package/pyproject.toml +29 -0
  79. package/session_client.py +97 -0
  80. package/session_daemon.py +325 -0
  81. package/src/browser_harness/__init__.py +2 -0
  82. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  83. package/src/browser_harness/__pycache__/_ipc.cpython-311.pyc +0 -0
  84. package/src/browser_harness/__pycache__/admin.cpython-311.pyc +0 -0
  85. package/src/browser_harness/__pycache__/auth.cpython-311.pyc +0 -0
  86. package/src/browser_harness/__pycache__/daemon.cpython-311.pyc +0 -0
  87. package/src/browser_harness/__pycache__/helpers.cpython-311.pyc +0 -0
  88. package/src/browser_harness/__pycache__/paths.cpython-311.pyc +0 -0
  89. package/src/browser_harness/__pycache__/recorder.cpython-311.pyc +0 -0
  90. package/src/browser_harness/_ipc.py +201 -0
  91. package/src/browser_harness/admin.py +1588 -0
  92. package/src/browser_harness/auth.py +546 -0
  93. package/src/browser_harness/daemon.py +894 -0
  94. package/src/browser_harness/helpers.py +668 -0
  95. package/src/browser_harness/macos.py +131 -0
  96. package/src/browser_harness/mcp_cli.py +16 -0
  97. package/src/browser_harness/paths.py +49 -0
  98. package/src/browser_harness/recorder.py +343 -0
  99. package/src/browser_harness/run.py +413 -0
  100. package/src/browser_harness/telemetry.py +308 -0
  101. package/src/browser_harness/video-template.html +970 -0
  102. package/src/browser_harness/video.py +749 -0
  103. package/src/browser_harness/video_render.py +524 -0
  104. package/src/browser_workspace.egg-info/PKG-INFO +10 -0
  105. package/src/browser_workspace.egg-info/SOURCES.txt +28 -0
  106. package/src/browser_workspace.egg-info/dependency_links.txt +1 -0
  107. package/src/browser_workspace.egg-info/requires.txt +5 -0
  108. package/src/browser_workspace.egg-info/top_level.txt +1 -0
@@ -0,0 +1,749 @@
1
+ #!/usr/bin/env python3
2
+ """Initialize, compile, review, and export browser-harness recordings."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import math
10
+ import re
11
+ import shutil
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ TEMPLATE = Path(__file__).with_name("video-template.html")
16
+ SOURCE_MANIFEST = "video-source.json"
17
+ COMPOSITION_PREFIX = "window.COMPOSITION ="
18
+ SENSITIVE = re.compile(
19
+ r"@|onmicrosoft\.com|(?:tenant|user|object)[_-]?id|"
20
+ r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b",
21
+ re.IGNORECASE,
22
+ )
23
+ ROUTE_UNSAFE = re.compile(
24
+ r"@|[?#]|://|onmicrosoft|(?:tenant|user|object)[_-]?id|"
25
+ r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b",
26
+ re.IGNORECASE,
27
+ )
28
+ OPAQUE_HEX = re.compile(r"^#[0-9a-f]{6}$", re.IGNORECASE)
29
+ HOUSE_STYLE = {
30
+ "version": 1,
31
+ "frameStyle": "native",
32
+ "readingWpm": 380,
33
+ "background": ["#efece4", "#dce7e7"],
34
+ "cursorStart": {"x": 700, "y": 280},
35
+ "pacing": {
36
+ "captionBaseSeconds": 0.35,
37
+ "captionSecondsPerWord": 0.2,
38
+ "rawToCardHoldSeconds": 0.55,
39
+ "baseDurationBudget": 22,
40
+ "extraActionSeconds": 1.25,
41
+ "extraExplanationSeconds": 3,
42
+ "maximumDurationBudget": 32,
43
+ },
44
+ "motion": {
45
+ "autoFollow": True,
46
+ "autoZoom": 1.7,
47
+ "cursorDuration": 0.48,
48
+ "zoomDuration": 0.42,
49
+ "panDuration": 0.55,
50
+ "wideScale": 0.78,
51
+ "reactionLag": 0.025,
52
+ "reactionFade": 0.04,
53
+ },
54
+ "privacy": {"pad": 10, "mask": {"fill": "#ffffff", "stroke": False, "radius": 0}},
55
+ }
56
+ ACTION_KEYS = {
57
+ "event",
58
+ "frameEvent",
59
+ "afterEvent",
60
+ "chapter",
61
+ "route",
62
+ "afterRoute",
63
+ "narration",
64
+ "label",
65
+ "detour",
66
+ "error",
67
+ "context",
68
+ "showTyping",
69
+ }
70
+ BRIEF_KEYS = {
71
+ "task",
72
+ "summary",
73
+ "plan",
74
+ "actions",
75
+ "explanations",
76
+ "outcomeTitle",
77
+ "outcomeSummary",
78
+ "outcomes",
79
+ "privacy",
80
+ }
81
+ PRIVACY_KEYS = {"reviewedFrames", "redact"}
82
+ EXPLANATION_KEYS = {
83
+ "afterAction",
84
+ "title",
85
+ "summary",
86
+ "observed",
87
+ "mistake",
88
+ "correction",
89
+ }
90
+ TYPE_HELPERS = {"type_text", "fill", "fill_input"}
91
+ CLICK_HELPERS = {"click_at_xy"}
92
+ REDACTION_KEYS = {"x", "y", "w", "h", "fill", "stroke", "radius", "pad"}
93
+ VIEWPORT_TOLERANCE = 2
94
+
95
+
96
+ class BriefError(ValueError):
97
+ """An edit brief violates the deliberately small authoring contract."""
98
+
99
+
100
+ def load_json(path: Path) -> dict[str, Any]:
101
+ try:
102
+ value = json.loads(path.read_text(encoding="utf-8"))
103
+ except (OSError, json.JSONDecodeError) as exc:
104
+ raise BriefError(f"cannot read {path}: {exc}") from exc
105
+ if not isinstance(value, dict):
106
+ raise BriefError(f"{path} must contain a JSON object")
107
+ return value
108
+
109
+
110
+ def file_hash(path: Path) -> str:
111
+ return hashlib.sha256(path.read_bytes()).hexdigest()
112
+
113
+
114
+ def load_composition(path: Path) -> dict[str, Any]:
115
+ try:
116
+ text = path.read_text(encoding="utf-8").strip()
117
+ except OSError as exc:
118
+ raise BriefError(f"cannot read {path}: {exc}") from exc
119
+ if not text.startswith(COMPOSITION_PREFIX) or not text.endswith(";"):
120
+ raise BriefError(f"{path} is not a generated composition")
121
+ try:
122
+ value = json.loads(text[len(COMPOSITION_PREFIX) : -1].strip())
123
+ except json.JSONDecodeError as exc:
124
+ raise BriefError(f"cannot read {path}: {exc}") from exc
125
+ if not isinstance(value, dict):
126
+ raise BriefError(f"{path} must set a JSON object")
127
+ return value
128
+
129
+
130
+ def used_frames(composition: dict[str, Any]) -> list[str]:
131
+ frames: list[str] = []
132
+ for beat in composition.get("beats") or []:
133
+ for key in ("frame", "after"):
134
+ frame = beat.get(key)
135
+ if frame and frame not in frames:
136
+ frames.append(str(frame))
137
+ return frames
138
+
139
+
140
+ def source_files(recording: Path) -> list[Path]:
141
+ required = [recording / name for name in ("events.jsonl", "meta.json", "recording-summary.json")]
142
+ frames = sorted(path for path in recording.glob("*.jpg") if path.stem.isdigit())
143
+ return [path for path in required if path.is_file()] + frames
144
+
145
+
146
+ def write_source_manifest(recording: Path) -> dict[str, Any]:
147
+ meta_path = recording / "meta.json"
148
+ meta = load_json(meta_path) if meta_path.is_file() else {}
149
+ files = source_files(recording)
150
+ manifest = {
151
+ "recording": recording.name,
152
+ "started": meta.get("started"),
153
+ "explicit": meta_path.is_file() and meta.get("auto") is not True,
154
+ "files": {path.name: file_hash(path) for path in files},
155
+ }
156
+ (recording / SOURCE_MANIFEST).write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
157
+ return manifest
158
+
159
+
160
+ def verify_source_manifest(recording: Path) -> dict[str, Any]:
161
+ manifest = load_json(recording / SOURCE_MANIFEST)
162
+ if manifest.get("recording") != recording.name:
163
+ raise BriefError("recording directory does not match video-source.json")
164
+ expected = manifest.get("files")
165
+ if not isinstance(expected, dict):
166
+ raise BriefError("video-source.json has no source hashes")
167
+ paths = source_files(recording)
168
+ if {path.name for path in paths} != set(expected):
169
+ raise BriefError("recording source files changed after initialization")
170
+ for path in paths:
171
+ if expected.get(path.name) != file_hash(path):
172
+ raise BriefError(f"recording source changed after initialization: {path.name}")
173
+ return manifest
174
+
175
+
176
+ def reject_unknown(value: dict[str, Any], allowed: set[str], where: str) -> None:
177
+ unknown = sorted(set(value) - allowed)
178
+ if unknown:
179
+ raise BriefError(f"{where} has unsupported field(s): {', '.join(unknown)}")
180
+
181
+
182
+ def require_text(value: Any, where: str) -> str:
183
+ if not isinstance(value, str) or not value.strip():
184
+ raise BriefError(f"{where} must be non-empty text")
185
+ return value.strip()
186
+
187
+
188
+ def require_text_list(value: Any, where: str, low: int, high: int) -> list[str]:
189
+ if not isinstance(value, list) or not low <= len(value) <= high:
190
+ raise BriefError(f"{where} must contain {low}–{high} items")
191
+ return [require_text(item, f"{where}[{index}]") for index, item in enumerate(value)]
192
+
193
+
194
+ def words(value: Any) -> int:
195
+ return len(re.findall(r"\S+", str(value or "")))
196
+
197
+
198
+ def card_duration(
199
+ title: str,
200
+ summary: str | None,
201
+ details: list[str],
202
+ kind: str,
203
+ reading_wpm: float,
204
+ ) -> float:
205
+ text = " ".join(part for part in (title, summary, *details) if part)
206
+ base = 4.5 if kind in {"intro", "outcome"} else 4.0
207
+ return round(max(base, 0.4 + words(text) * 60 / reading_wpm), 3)
208
+
209
+
210
+ def validate_narration(value: Any, where: str) -> str | None:
211
+ if value is None:
212
+ return None
213
+ if not isinstance(value, str):
214
+ raise BriefError(f"{where} must be text")
215
+ if words(value) > 7:
216
+ raise BriefError(f"{where} exceeds seven words")
217
+ return value.strip()
218
+
219
+
220
+ def optional_text(value: Any, where: str) -> str | None:
221
+ if value is None:
222
+ return None
223
+ return require_text(value, where)
224
+
225
+
226
+ def event_at(events: list[dict[str, Any]], number: Any, where: str) -> dict[str, Any]:
227
+ if not isinstance(number, int) or isinstance(number, bool):
228
+ raise BriefError(f"{where} must be a one-based integer")
229
+ if number < 1 or number > len(events):
230
+ raise BriefError(f"{where} is outside recording-summary.json")
231
+ event = events[number - 1]
232
+ if not event.get("frame"):
233
+ raise BriefError(f"{where} has no captured frame")
234
+ return event
235
+
236
+
237
+ def event_target(event: dict[str, Any]) -> dict[str, float] | None:
238
+ cursor = event.get("cursor")
239
+ if isinstance(cursor, dict) and cursor.get("x") is not None and cursor.get("y") is not None:
240
+ return {"x": float(cursor["x"]), "y": float(cursor["y"])}
241
+ box = event.get("box")
242
+ if isinstance(box, dict) and all(box.get(key) is not None for key in ("x", "y", "w", "h")):
243
+ return {
244
+ "x": float(box["x"]) + float(box["w"]) * 0.3,
245
+ "y": float(box["y"]) + float(box["h"]) / 2,
246
+ }
247
+ return None
248
+
249
+
250
+ def require_matching_viewport(event: dict[str, Any], viewport: dict[str, Any], where: str) -> None:
251
+ candidate = event.get("viewport") or {}
252
+ try:
253
+ dw = abs(float(candidate["w"]) - float(viewport["w"]))
254
+ dh = abs(float(candidate["h"]) - float(viewport["h"]))
255
+ except (KeyError, TypeError, ValueError):
256
+ raise BriefError(f"{where} has no valid viewport") from None
257
+ if dw > VIEWPORT_TOLERANCE or dh > VIEWPORT_TOLERANCE:
258
+ raise BriefError(f"{where} uses a different viewport; split or normalize the recording first")
259
+
260
+
261
+ def default_action_duration(beat: dict[str, Any], pacing: dict[str, Any]) -> float:
262
+ base = 0.7
263
+ if beat.get("click"):
264
+ base = 1.15
265
+ if beat.get("after"):
266
+ base = max(base, 1.4)
267
+ typing = beat.get("type")
268
+ if typing:
269
+ base = max(base, 0.6 + len(str(typing.get("text") or "")) * 0.035)
270
+ narration = beat.get("narration")
271
+ if narration:
272
+ base = max(
273
+ base,
274
+ float(pacing["captionBaseSeconds"])
275
+ + float(pacing["captionSecondsPerWord"]) * words(narration),
276
+ )
277
+ return round(base, 3)
278
+
279
+
280
+ def duration_budget(
281
+ action_count: int,
282
+ explanation_count: int,
283
+ raw_to_card_count: int,
284
+ pacing: dict[str, Any],
285
+ ) -> float:
286
+ budget = float(pacing["baseDurationBudget"])
287
+ budget += max(0, action_count - 5) * float(pacing["extraActionSeconds"])
288
+ budget += max(0, explanation_count - 1) * float(
289
+ pacing["extraExplanationSeconds"]
290
+ )
291
+ budget += raw_to_card_count * float(pacing["rawToCardHoldSeconds"])
292
+ return round(min(budget, float(pacing["maximumDurationBudget"])), 3)
293
+
294
+
295
+ def add_raw_to_card_holds(beats: list[dict[str, Any]], pacing: dict[str, Any]) -> int:
296
+ hold = float(pacing["rawToCardHoldSeconds"])
297
+ count = 0
298
+ for beat, next_beat in zip(beats, beats[1:]):
299
+ if beat.get("card") or not next_beat.get("card"):
300
+ continue
301
+ beat["endStateHold"] = hold
302
+ beat["dur"] = round(float(beat["dur"]) + hold, 3)
303
+ count += 1
304
+ return count
305
+
306
+
307
+ def validate_narration_cadence(beats: list[dict[str, Any]]) -> None:
308
+ """Keep narration semantic and sticky instead of mirroring every frame."""
309
+
310
+ segments: list[list[dict[str, Any]]] = []
311
+ current: list[dict[str, Any]] = []
312
+ for beat in beats:
313
+ if beat.get("card"):
314
+ if current:
315
+ segments.append(current)
316
+ current = []
317
+ else:
318
+ current.append(beat)
319
+ if current:
320
+ segments.append(current)
321
+
322
+ for segment in segments:
323
+ cues = [beat for beat in segment if beat.get("narration")]
324
+ if len(segment) >= 3 and len(cues) > math.ceil(len(segment) / 2):
325
+ raise BriefError(
326
+ "narration is sticky: set it only when the thought changes, then "
327
+ "omit it while 2–3 screenshots advance underneath"
328
+ )
329
+ consecutive = 0
330
+ for beat in segment:
331
+ consecutive = consecutive + 1 if beat.get("narration") else 0
332
+ if consecutive >= 3:
333
+ raise BriefError(
334
+ "three consecutive actions change narration; omit narration on "
335
+ "intervening actions so text and screenshots use different pacing"
336
+ )
337
+
338
+
339
+ def compile_action(
340
+ action: dict[str, Any],
341
+ index: int,
342
+ events: list[dict[str, Any]],
343
+ plan: list[str],
344
+ first_ts: float,
345
+ previous_target: dict[str, float] | None,
346
+ viewport: dict[str, float],
347
+ pacing: dict[str, Any],
348
+ revealed_text: dict[int, str],
349
+ ) -> tuple[dict[str, Any], dict[str, float] | None]:
350
+ if not isinstance(action, dict):
351
+ raise BriefError(f"actions[{index}] must be an object")
352
+ reject_unknown(action, ACTION_KEYS, f"actions[{index}]")
353
+ if "showTyping" in action and not isinstance(action["showTyping"], bool):
354
+ raise BriefError(f"actions[{index}].showTyping must be true or false")
355
+ event = event_at(events, action.get("event"), f"actions[{index}].event")
356
+ require_matching_viewport(event, viewport, f"actions[{index}].event")
357
+ frame_event = event
358
+ if action.get("frameEvent") is not None:
359
+ frame_event = event_at(
360
+ events, action["frameEvent"], f"actions[{index}].frameEvent"
361
+ )
362
+ require_matching_viewport(frame_event, viewport, f"actions[{index}].frameEvent")
363
+ chapter = action.get("chapter")
364
+ if not isinstance(chapter, int) or isinstance(chapter, bool) or not 0 <= chapter < len(plan):
365
+ raise BriefError(f"actions[{index}].chapter must index plan")
366
+ route = require_text(action.get("route"), f"actions[{index}].route")
367
+ if ROUTE_UNSAFE.search(route):
368
+ raise BriefError(f"actions[{index}].route must be semantic, not a raw URL or identity")
369
+
370
+ beat: dict[str, Any] = {
371
+ "frame": frame_event["frame"],
372
+ "route": route,
373
+ "chapter": chapter,
374
+ }
375
+ after_number = action.get("afterEvent")
376
+ if after_number is not None:
377
+ after = event_at(events, after_number, f"actions[{index}].afterEvent")
378
+ require_matching_viewport(after, viewport, f"actions[{index}].afterEvent")
379
+ beat["after"] = after["frame"]
380
+ after_route = action.get("afterRoute")
381
+ if after_route is not None:
382
+ after_route = require_text(after_route, f"actions[{index}].afterRoute")
383
+ if ROUTE_UNSAFE.search(after_route):
384
+ raise BriefError(f"actions[{index}].afterRoute must be semantic")
385
+ beat["afterRoute"] = after_route
386
+
387
+ narration = validate_narration(action.get("narration"), f"actions[{index}].narration")
388
+ if narration is not None:
389
+ beat["narration"] = narration
390
+ if action.get("label") is not None:
391
+ beat["label"] = require_text(action["label"], f"actions[{index}].label")
392
+ if action.get("detour") is True:
393
+ beat["detour"] = True
394
+ if action.get("error") is True:
395
+ beat["error"] = True
396
+
397
+ helper = str(event.get("helper") or "")
398
+ cursor = event.get("cursor")
399
+ if helper in CLICK_HELPERS:
400
+ if not isinstance(cursor, dict) or cursor.get("x") is None or cursor.get("y") is None:
401
+ raise BriefError(f"actions[{index}] identifies a click without captured coordinates")
402
+ beat["cursor"] = {"x": cursor["x"], "y": cursor["y"]}
403
+ beat["click"] = True
404
+ elif helper in TYPE_HELPERS:
405
+ box = event.get("box")
406
+ if not isinstance(box, dict) or not all(box.get(key) is not None for key in ("x", "y", "w", "h")):
407
+ raise BriefError(f"actions[{index}] identifies typing without a captured box")
408
+ show_typing = action.get("showTyping") is True
409
+ if show_typing and event.get("password"):
410
+ raise BriefError(f"actions[{index}].showTyping cannot reveal a password field")
411
+ source_line = event.get("sourceLine")
412
+ if show_typing and source_line not in revealed_text:
413
+ raise BriefError(f"actions[{index}].showTyping requires the original typed event")
414
+ beat["type"] = {
415
+ "box": {key: box[key] for key in ("x", "y", "w", "h")},
416
+ "text": revealed_text[source_line] if show_typing else "••••••",
417
+ **({} if show_typing else {"redact": True}),
418
+ }
419
+ elif action.get("showTyping") is not None:
420
+ raise BriefError(f"actions[{index}].showTyping requires a typing event")
421
+
422
+ target = event_target(event)
423
+ if action.get("context") is True and not (beat.get("click") or beat.get("type")):
424
+ beat["wide"] = True
425
+ elif target and previous_target:
426
+ distance = math.hypot(target["x"] - previous_target["x"], target["y"] - previous_target["y"])
427
+ diagonal = math.hypot(float(viewport["w"]), float(viewport["h"]))
428
+ if distance > diagonal * 0.58:
429
+ beat["cameraCut"] = True
430
+
431
+ ts = event.get("ts")
432
+ if isinstance(ts, (int, float)):
433
+ beat["t"] = round(max(0.0, float(ts) - first_ts), 3)
434
+ beat["dur"] = default_action_duration(beat, pacing)
435
+ return beat, target or previous_target
436
+
437
+
438
+ def validate_privacy(reviewed: list[str], redact: dict[str, Any], composition: dict[str, Any]) -> None:
439
+ frames = used_frames(composition)
440
+ for frame in (*frames, *reviewed, *redact):
441
+ if Path(frame).name != frame or not frame.lower().endswith(".jpg"):
442
+ raise BriefError(f"invalid frame name: {frame}")
443
+ if len(reviewed) != len(set(reviewed)):
444
+ raise BriefError("privacy.reviewedFrames contains duplicates")
445
+ missing = [frame for frame in frames if frame not in reviewed]
446
+ if missing:
447
+ raise BriefError("privacy review missing: " + ", ".join(missing))
448
+ unknown = sorted(set(redact) - set(frames))
449
+ if unknown:
450
+ raise BriefError("privacy.redact lists unused frames: " + ", ".join(unknown))
451
+ for frame, rectangles in redact.items():
452
+ if not isinstance(rectangles, list):
453
+ raise BriefError(f"privacy.redact.{frame} must be a list")
454
+ for index, rectangle in enumerate(rectangles):
455
+ where = f"privacy.redact.{frame}[{index}]"
456
+ if not isinstance(rectangle, dict):
457
+ raise BriefError(f"{where} must be an object")
458
+ reject_unknown(rectangle, REDACTION_KEYS, where)
459
+ for key in ("x", "y", "w", "h"):
460
+ value = rectangle.get(key)
461
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value):
462
+ raise BriefError(f"{where}.{key} must be a finite number")
463
+ if rectangle["w"] <= 0 or rectangle["h"] <= 0:
464
+ raise BriefError(f"{where} must have positive width and height")
465
+ for key in ("fill", "stroke"):
466
+ value = rectangle.get(key)
467
+ if value is not None and value is not False and (
468
+ not isinstance(value, str) or not OPAQUE_HEX.fullmatch(value)
469
+ ):
470
+ raise BriefError(f"{where}.{key} must be false or opaque six-digit hex")
471
+
472
+
473
+ def compile_brief(summary: dict[str, Any], brief: dict[str, Any], style: dict[str, Any], revealed_text: dict[int, str] | None = None) -> dict[str, Any]:
474
+ reject_unknown(brief, BRIEF_KEYS, "edit brief")
475
+ task = require_text(brief.get("task"), "task")
476
+ summary_text = optional_text(brief.get("summary"), "summary")
477
+ plan = require_text_list(brief.get("plan"), "plan", 2, 5)
478
+ outcomes = require_text_list(brief.get("outcomes"), "outcomes", 1, 5)
479
+ actions = brief.get("actions")
480
+ if not isinstance(actions, list) or not actions:
481
+ raise BriefError("actions must contain at least one action")
482
+ events = summary.get("events")
483
+ if not isinstance(events, list) or not events:
484
+ raise BriefError("recording-summary.json has no events")
485
+ first_action = actions[0]
486
+ if not isinstance(first_action, dict):
487
+ raise BriefError("actions[0] must be an object")
488
+ viewport_event = event_at(
489
+ events,
490
+ first_action.get("frameEvent", first_action.get("event")),
491
+ "actions[0].frameEvent" if "frameEvent" in first_action else "actions[0].event",
492
+ )
493
+ if not (viewport_event.get("viewport") or {}).get("w") or not (viewport_event.get("viewport") or {}).get("h"):
494
+ raise BriefError("recording-summary.json has no viewport")
495
+ viewport = viewport_event["viewport"]
496
+ first_ts = next(
497
+ (float(event["ts"]) for event in events if isinstance(event.get("ts"), (int, float))),
498
+ 0.0,
499
+ )
500
+
501
+ privacy = brief.get("privacy")
502
+ if not isinstance(privacy, dict):
503
+ raise BriefError("privacy must be an object")
504
+ reject_unknown(privacy, PRIVACY_KEYS, "privacy")
505
+ reviewed = privacy.get("reviewedFrames")
506
+ if not isinstance(reviewed, list) or not all(isinstance(frame, str) for frame in reviewed):
507
+ raise BriefError("privacy.reviewedFrames must be a list of frame names")
508
+ redact = privacy.get("redact") or {}
509
+ if not isinstance(redact, dict):
510
+ raise BriefError("privacy.redact must be an object")
511
+
512
+ explanations = brief.get("explanations") or []
513
+ if not isinstance(explanations, list):
514
+ raise BriefError("explanations must be a list")
515
+ pacing = style["pacing"]
516
+ reading_wpm = float(style["readingWpm"])
517
+ explanation_by_action: dict[int, list[dict[str, Any]]] = {}
518
+ revealed_text = revealed_text or {}
519
+ for index, explanation in enumerate(explanations):
520
+ if not isinstance(explanation, dict):
521
+ raise BriefError(f"explanations[{index}] must be an object")
522
+ reject_unknown(explanation, EXPLANATION_KEYS, f"explanations[{index}]")
523
+ after_action = explanation.get("afterAction")
524
+ if not isinstance(after_action, int) or isinstance(after_action, bool) or not 1 <= after_action <= len(actions):
525
+ raise BriefError(f"explanations[{index}].afterAction must index actions")
526
+ title = require_text(explanation.get("title"), f"explanations[{index}].title")
527
+ sub = optional_text(explanation.get("summary"), f"explanations[{index}].summary")
528
+ points = [
529
+ {"label": "Observed", "text": require_text(explanation.get("observed"), f"explanations[{index}].observed")},
530
+ {"label": "Mistake", "text": require_text(explanation.get("mistake"), f"explanations[{index}].mistake")},
531
+ {"label": "Correction", "text": require_text(explanation.get("correction"), f"explanations[{index}].correction")},
532
+ ]
533
+ card = {
534
+ "card": True,
535
+ "kind": "explanation",
536
+ "title": title,
537
+ **({"sub": sub} if sub is not None else {}),
538
+ "points": points,
539
+ "dur": card_duration(
540
+ title,
541
+ sub,
542
+ [part for point in points for part in (point["label"], point["text"])],
543
+ "explanation",
544
+ reading_wpm,
545
+ ),
546
+ }
547
+ explanation_by_action.setdefault(after_action, []).append(card)
548
+
549
+ intro = {
550
+ "card": True,
551
+ "kind": "intro",
552
+ "title": task,
553
+ **({"sub": summary_text} if summary_text is not None else {}),
554
+ "dur": card_duration(task, summary_text, plan, "intro", reading_wpm),
555
+ }
556
+ beats: list[dict[str, Any]] = [intro]
557
+ previous_target = None
558
+ for index, action in enumerate(actions):
559
+ beat, previous_target = compile_action(
560
+ action, index, events, plan, first_ts, previous_target, viewport, pacing, revealed_text
561
+ )
562
+ beats.append(beat)
563
+ beats.extend(explanation_by_action.get(index + 1, []))
564
+
565
+ outcome_title = require_text(brief.get("outcomeTitle") or "Task complete", "outcomeTitle")
566
+ outcome_summary = optional_text(brief.get("outcomeSummary"), "outcomeSummary")
567
+ beats.append(
568
+ {
569
+ "card": True,
570
+ "kind": "outcome",
571
+ "title": outcome_title,
572
+ **({"sub": outcome_summary} if outcome_summary is not None else {}),
573
+ "outcomes": outcomes,
574
+ "dur": card_duration(
575
+ outcome_title, outcome_summary, outcomes, "outcome", reading_wpm
576
+ ),
577
+ }
578
+ )
579
+
580
+ validate_narration_cadence(beats)
581
+ raw_to_card_count = add_raw_to_card_holds(beats, pacing)
582
+ budget = duration_budget(
583
+ len(actions), len(explanations), raw_to_card_count, pacing
584
+ )
585
+ duration = round(sum(float(beat["dur"]) for beat in beats), 3)
586
+ if duration > budget + 0.001:
587
+ raise BriefError(
588
+ f"compiled video is {duration:.1f}s; house-style budget is "
589
+ f"{budget:.1f}s. Shorten card copy, remove redundant actions, or set "
590
+ "narration only when the thought changes; viewers can pause for detail"
591
+ )
592
+
593
+ house_privacy = style["privacy"]
594
+ composition = {
595
+ "schemaVersion": style["version"],
596
+ "viewport": {"w": viewport["w"], "h": viewport["h"]},
597
+ "cursorStart": style["cursorStart"],
598
+ "frameStyle": style["frameStyle"],
599
+ "readingWpm": style["readingWpm"],
600
+ "pacing": pacing,
601
+ "durationBudget": budget,
602
+ "bg": style["background"],
603
+ "plan": plan,
604
+ "motion": style["motion"],
605
+ "privacy": {
606
+ "reviewedFrames": reviewed,
607
+ "pad": house_privacy["pad"],
608
+ "mask": house_privacy["mask"],
609
+ },
610
+ "redact": redact,
611
+ "beats": beats,
612
+ }
613
+ validate_privacy(reviewed, redact, composition)
614
+ return composition
615
+
616
+
617
+ def write_composition(path: Path, composition: dict[str, Any]) -> None:
618
+ body = json.dumps(composition, indent=2, ensure_ascii=False)
619
+ path.write_text(f"window.COMPOSITION = {body};\n", encoding="utf-8")
620
+
621
+
622
+ def load_revealed_text(events_path: Path) -> dict[int, str]:
623
+ revealed: dict[int, str] = {}
624
+ try:
625
+ lines = events_path.read_text(encoding="utf-8").splitlines()
626
+ except OSError as exc:
627
+ raise BriefError(f"cannot read {events_path}: {exc}") from exc
628
+ for source_line, line in enumerate(lines, 1):
629
+ if not line.strip():
630
+ continue
631
+ try:
632
+ event = json.loads(line)
633
+ except json.JSONDecodeError as exc:
634
+ raise BriefError(f"cannot read {events_path}: {exc}") from exc
635
+ if event.get("helper") in TYPE_HELPERS and event.get("input") != "password":
636
+ text = event.get("text")
637
+ if text is not None:
638
+ revealed[source_line] = str(text)
639
+ return revealed
640
+
641
+
642
+ def safe_text(event: dict[str, Any]) -> str | None:
643
+ value = event.get("text")
644
+ if value is None:
645
+ return None
646
+ if event.get("helper") in TYPE_HELPERS:
647
+ return "<typed text hidden>"
648
+ value = str(value)
649
+ if event.get("input") == "password" or SENSITIVE.search(value):
650
+ return "<sensitive>"
651
+ return value[:120]
652
+
653
+
654
+ def safe_label(value: object) -> str | None:
655
+ if value is None:
656
+ return None
657
+ value = str(value)
658
+ return "<sensitive>" if SENSITIVE.search(value) else value[:120]
659
+
660
+
661
+ def init_recording(recording: Path, require_explicit: bool = False) -> int:
662
+ events_path = recording / "events.jsonl"
663
+ if not events_path.is_file():
664
+ raise BriefError(f"missing {events_path}")
665
+ meta_path = recording / "meta.json"
666
+ meta = load_json(meta_path) if meta_path.is_file() else {}
667
+ if require_explicit and (not meta_path.is_file() or meta.get("auto") is True):
668
+ raise BriefError("not an explicit recording; use the exact path returned by start_recording()")
669
+
670
+ shutil.copy2(TEMPLATE, recording / "video.html")
671
+ events = []
672
+ for source_line, line in enumerate(events_path.read_text(encoding="utf-8").splitlines(), 1):
673
+ if not line.strip():
674
+ continue
675
+ try:
676
+ raw = json.loads(line)
677
+ except json.JSONDecodeError as exc:
678
+ raise BriefError(f"cannot read {events_path}: {exc}") from exc
679
+ if not raw.get("frame"):
680
+ continue
681
+ events.append(
682
+ {
683
+ "frame": raw["frame"],
684
+ "sourceLine": source_line,
685
+ "helper": raw.get("helper"),
686
+ "ts": raw.get("ts"),
687
+ "route": "Browser",
688
+ "tab": safe_label(raw.get("title")),
689
+ "viewport": {"w": raw.get("w"), "h": raw.get("h")},
690
+ "cursor": (
691
+ {"x": raw.get("x"), "y": raw.get("y")}
692
+ if raw.get("x") is not None and raw.get("y") is not None
693
+ else None
694
+ ),
695
+ "box": raw.get("box"),
696
+ "text": safe_text(raw),
697
+ "textLength": len(str(raw.get("text") or "")),
698
+ "password": raw.get("input") == "password",
699
+ }
700
+ )
701
+
702
+ summary = {
703
+ "recording": recording.name,
704
+ "title": safe_label(meta.get("title")),
705
+ "eventCount": len(events),
706
+ "events": events,
707
+ }
708
+ output = recording / "recording-summary.json"
709
+ output.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
710
+ write_source_manifest(recording)
711
+ print(f"summary: {output}")
712
+ print(f"next: write {recording / 'edit-brief.json'}, then run browser-harness video review")
713
+ return 0
714
+
715
+
716
+ def run_cli(args: list[str]) -> int:
717
+ parser = argparse.ArgumentParser(prog="browser-harness video")
718
+ sub = parser.add_subparsers(dest="command", required=True)
719
+ init = sub.add_parser("init", help="prepare a recording for editing")
720
+ init.add_argument("recording", type=Path)
721
+ init.add_argument("--require-explicit", action="store_true")
722
+ review = sub.add_parser("review", help="compile and generate a review sheet")
723
+ review.add_argument("recording", type=Path)
724
+ export = sub.add_parser("export", help="export a reviewed MP4")
725
+ export.add_argument("recording", type=Path)
726
+ export.add_argument("--output", default="video.mp4")
727
+ export.add_argument("--reviewed", action="store_true")
728
+ parsed = parser.parse_args(args)
729
+ recording = parsed.recording.expanduser().resolve()
730
+ try:
731
+ if parsed.command == "init":
732
+ return init_recording(recording, parsed.require_explicit)
733
+ from . import video_render
734
+
735
+ if parsed.command == "review":
736
+ return video_render.review(recording)
737
+ return video_render.export(recording, parsed.output, parsed.reviewed)
738
+ except (OSError, ValueError, RuntimeError) as exc:
739
+ parser.error(str(exc))
740
+
741
+
742
+ def main() -> int:
743
+ import sys
744
+
745
+ return run_cli(sys.argv[1:])
746
+
747
+
748
+ if __name__ == "__main__":
749
+ raise SystemExit(main())