Files
workbuddy_skills/browser-harness/src/browser_harness/video.py
T

749 lines
29 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Initialize, compile, review, and export browser-harness recordings."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import re
import shutil
from pathlib import Path
from typing import Any
TEMPLATE = Path(__file__).with_name("video-template.html")
SOURCE_MANIFEST = "video-source.json"
COMPOSITION_PREFIX = "window.COMPOSITION ="
SENSITIVE = re.compile(
r"@|onmicrosoft\.com|(?:tenant|user|object)[_-]?id|"
r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b",
re.IGNORECASE,
)
ROUTE_UNSAFE = re.compile(
r"@|[?#]|://|onmicrosoft|(?:tenant|user|object)[_-]?id|"
r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b",
re.IGNORECASE,
)
OPAQUE_HEX = re.compile(r"^#[0-9a-f]{6}$", re.IGNORECASE)
HOUSE_STYLE = {
"version": 1,
"frameStyle": "native",
"readingWpm": 380,
"background": ["#efece4", "#dce7e7"],
"cursorStart": {"x": 700, "y": 280},
"pacing": {
"captionBaseSeconds": 0.35,
"captionSecondsPerWord": 0.2,
"rawToCardHoldSeconds": 0.55,
"baseDurationBudget": 22,
"extraActionSeconds": 1.25,
"extraExplanationSeconds": 3,
"maximumDurationBudget": 32,
},
"motion": {
"autoFollow": True,
"autoZoom": 1.7,
"cursorDuration": 0.48,
"zoomDuration": 0.42,
"panDuration": 0.55,
"wideScale": 0.78,
"reactionLag": 0.025,
"reactionFade": 0.04,
},
"privacy": {"pad": 10, "mask": {"fill": "#ffffff", "stroke": False, "radius": 0}},
}
ACTION_KEYS = {
"event",
"frameEvent",
"afterEvent",
"chapter",
"route",
"afterRoute",
"narration",
"label",
"detour",
"error",
"context",
"showTyping",
}
BRIEF_KEYS = {
"task",
"summary",
"plan",
"actions",
"explanations",
"outcomeTitle",
"outcomeSummary",
"outcomes",
"privacy",
}
PRIVACY_KEYS = {"reviewedFrames", "redact"}
EXPLANATION_KEYS = {
"afterAction",
"title",
"summary",
"observed",
"mistake",
"correction",
}
TYPE_HELPERS = {"type_text", "fill", "fill_input"}
CLICK_HELPERS = {"click_at_xy"}
REDACTION_KEYS = {"x", "y", "w", "h", "fill", "stroke", "radius", "pad"}
VIEWPORT_TOLERANCE = 2
class BriefError(ValueError):
"""An edit brief violates the deliberately small authoring contract."""
def load_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise BriefError(f"cannot read {path}: {exc}") from exc
if not isinstance(value, dict):
raise BriefError(f"{path} must contain a JSON object")
return value
def file_hash(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def load_composition(path: Path) -> dict[str, Any]:
try:
text = path.read_text(encoding="utf-8").strip()
except OSError as exc:
raise BriefError(f"cannot read {path}: {exc}") from exc
if not text.startswith(COMPOSITION_PREFIX) or not text.endswith(";"):
raise BriefError(f"{path} is not a generated composition")
try:
value = json.loads(text[len(COMPOSITION_PREFIX) : -1].strip())
except json.JSONDecodeError as exc:
raise BriefError(f"cannot read {path}: {exc}") from exc
if not isinstance(value, dict):
raise BriefError(f"{path} must set a JSON object")
return value
def used_frames(composition: dict[str, Any]) -> list[str]:
frames: list[str] = []
for beat in composition.get("beats") or []:
for key in ("frame", "after"):
frame = beat.get(key)
if frame and frame not in frames:
frames.append(str(frame))
return frames
def source_files(recording: Path) -> list[Path]:
required = [recording / name for name in ("events.jsonl", "meta.json", "recording-summary.json")]
frames = sorted(path for path in recording.glob("*.jpg") if path.stem.isdigit())
return [path for path in required if path.is_file()] + frames
def write_source_manifest(recording: Path) -> dict[str, Any]:
meta_path = recording / "meta.json"
meta = load_json(meta_path) if meta_path.is_file() else {}
files = source_files(recording)
manifest = {
"recording": recording.name,
"started": meta.get("started"),
"explicit": meta_path.is_file() and meta.get("auto") is not True,
"files": {path.name: file_hash(path) for path in files},
}
(recording / SOURCE_MANIFEST).write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return manifest
def verify_source_manifest(recording: Path) -> dict[str, Any]:
manifest = load_json(recording / SOURCE_MANIFEST)
if manifest.get("recording") != recording.name:
raise BriefError("recording directory does not match video-source.json")
expected = manifest.get("files")
if not isinstance(expected, dict):
raise BriefError("video-source.json has no source hashes")
paths = source_files(recording)
if {path.name for path in paths} != set(expected):
raise BriefError("recording source files changed after initialization")
for path in paths:
if expected.get(path.name) != file_hash(path):
raise BriefError(f"recording source changed after initialization: {path.name}")
return manifest
def reject_unknown(value: dict[str, Any], allowed: set[str], where: str) -> None:
unknown = sorted(set(value) - allowed)
if unknown:
raise BriefError(f"{where} has unsupported field(s): {', '.join(unknown)}")
def require_text(value: Any, where: str) -> str:
if not isinstance(value, str) or not value.strip():
raise BriefError(f"{where} must be non-empty text")
return value.strip()
def require_text_list(value: Any, where: str, low: int, high: int) -> list[str]:
if not isinstance(value, list) or not low <= len(value) <= high:
raise BriefError(f"{where} must contain {low}–{high} items")
return [require_text(item, f"{where}[{index}]") for index, item in enumerate(value)]
def words(value: Any) -> int:
return len(re.findall(r"\S+", str(value or "")))
def card_duration(
title: str,
summary: str | None,
details: list[str],
kind: str,
reading_wpm: float,
) -> float:
text = " ".join(part for part in (title, summary, *details) if part)
base = 4.5 if kind in {"intro", "outcome"} else 4.0
return round(max(base, 0.4 + words(text) * 60 / reading_wpm), 3)
def validate_narration(value: Any, where: str) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise BriefError(f"{where} must be text")
if words(value) > 7:
raise BriefError(f"{where} exceeds seven words")
return value.strip()
def optional_text(value: Any, where: str) -> str | None:
if value is None:
return None
return require_text(value, where)
def event_at(events: list[dict[str, Any]], number: Any, where: str) -> dict[str, Any]:
if not isinstance(number, int) or isinstance(number, bool):
raise BriefError(f"{where} must be a one-based integer")
if number < 1 or number > len(events):
raise BriefError(f"{where} is outside recording-summary.json")
event = events[number - 1]
if not event.get("frame"):
raise BriefError(f"{where} has no captured frame")
return event
def event_target(event: dict[str, Any]) -> dict[str, float] | None:
cursor = event.get("cursor")
if isinstance(cursor, dict) and cursor.get("x") is not None and cursor.get("y") is not None:
return {"x": float(cursor["x"]), "y": float(cursor["y"])}
box = event.get("box")
if isinstance(box, dict) and all(box.get(key) is not None for key in ("x", "y", "w", "h")):
return {
"x": float(box["x"]) + float(box["w"]) * 0.3,
"y": float(box["y"]) + float(box["h"]) / 2,
}
return None
def require_matching_viewport(event: dict[str, Any], viewport: dict[str, Any], where: str) -> None:
candidate = event.get("viewport") or {}
try:
dw = abs(float(candidate["w"]) - float(viewport["w"]))
dh = abs(float(candidate["h"]) - float(viewport["h"]))
except (KeyError, TypeError, ValueError):
raise BriefError(f"{where} has no valid viewport") from None
if dw > VIEWPORT_TOLERANCE or dh > VIEWPORT_TOLERANCE:
raise BriefError(f"{where} uses a different viewport; split or normalize the recording first")
def default_action_duration(beat: dict[str, Any], pacing: dict[str, Any]) -> float:
base = 0.7
if beat.get("click"):
base = 1.15
if beat.get("after"):
base = max(base, 1.4)
typing = beat.get("type")
if typing:
base = max(base, 0.6 + len(str(typing.get("text") or "")) * 0.035)
narration = beat.get("narration")
if narration:
base = max(
base,
float(pacing["captionBaseSeconds"])
+ float(pacing["captionSecondsPerWord"]) * words(narration),
)
return round(base, 3)
def duration_budget(
action_count: int,
explanation_count: int,
raw_to_card_count: int,
pacing: dict[str, Any],
) -> float:
budget = float(pacing["baseDurationBudget"])
budget += max(0, action_count - 5) * float(pacing["extraActionSeconds"])
budget += max(0, explanation_count - 1) * float(
pacing["extraExplanationSeconds"]
)
budget += raw_to_card_count * float(pacing["rawToCardHoldSeconds"])
return round(min(budget, float(pacing["maximumDurationBudget"])), 3)
def add_raw_to_card_holds(beats: list[dict[str, Any]], pacing: dict[str, Any]) -> int:
hold = float(pacing["rawToCardHoldSeconds"])
count = 0
for beat, next_beat in zip(beats, beats[1:]):
if beat.get("card") or not next_beat.get("card"):
continue
beat["endStateHold"] = hold
beat["dur"] = round(float(beat["dur"]) + hold, 3)
count += 1
return count
def validate_narration_cadence(beats: list[dict[str, Any]]) -> None:
"""Keep narration semantic and sticky instead of mirroring every frame."""
segments: list[list[dict[str, Any]]] = []
current: list[dict[str, Any]] = []
for beat in beats:
if beat.get("card"):
if current:
segments.append(current)
current = []
else:
current.append(beat)
if current:
segments.append(current)
for segment in segments:
cues = [beat for beat in segment if beat.get("narration")]
if len(segment) >= 3 and len(cues) > math.ceil(len(segment) / 2):
raise BriefError(
"narration is sticky: set it only when the thought changes, then "
"omit it while 2–3 screenshots advance underneath"
)
consecutive = 0
for beat in segment:
consecutive = consecutive + 1 if beat.get("narration") else 0
if consecutive >= 3:
raise BriefError(
"three consecutive actions change narration; omit narration on "
"intervening actions so text and screenshots use different pacing"
)
def compile_action(
action: dict[str, Any],
index: int,
events: list[dict[str, Any]],
plan: list[str],
first_ts: float,
previous_target: dict[str, float] | None,
viewport: dict[str, float],
pacing: dict[str, Any],
revealed_text: dict[int, str],
) -> tuple[dict[str, Any], dict[str, float] | None]:
if not isinstance(action, dict):
raise BriefError(f"actions[{index}] must be an object")
reject_unknown(action, ACTION_KEYS, f"actions[{index}]")
if "showTyping" in action and not isinstance(action["showTyping"], bool):
raise BriefError(f"actions[{index}].showTyping must be true or false")
event = event_at(events, action.get("event"), f"actions[{index}].event")
require_matching_viewport(event, viewport, f"actions[{index}].event")
frame_event = event
if action.get("frameEvent") is not None:
frame_event = event_at(
events, action["frameEvent"], f"actions[{index}].frameEvent"
)
require_matching_viewport(frame_event, viewport, f"actions[{index}].frameEvent")
chapter = action.get("chapter")
if not isinstance(chapter, int) or isinstance(chapter, bool) or not 0 <= chapter < len(plan):
raise BriefError(f"actions[{index}].chapter must index plan")
route = require_text(action.get("route"), f"actions[{index}].route")
if ROUTE_UNSAFE.search(route):
raise BriefError(f"actions[{index}].route must be semantic, not a raw URL or identity")
beat: dict[str, Any] = {
"frame": frame_event["frame"],
"route": route,
"chapter": chapter,
}
after_number = action.get("afterEvent")
if after_number is not None:
after = event_at(events, after_number, f"actions[{index}].afterEvent")
require_matching_viewport(after, viewport, f"actions[{index}].afterEvent")
beat["after"] = after["frame"]
after_route = action.get("afterRoute")
if after_route is not None:
after_route = require_text(after_route, f"actions[{index}].afterRoute")
if ROUTE_UNSAFE.search(after_route):
raise BriefError(f"actions[{index}].afterRoute must be semantic")
beat["afterRoute"] = after_route
narration = validate_narration(action.get("narration"), f"actions[{index}].narration")
if narration is not None:
beat["narration"] = narration
if action.get("label") is not None:
beat["label"] = require_text(action["label"], f"actions[{index}].label")
if action.get("detour") is True:
beat["detour"] = True
if action.get("error") is True:
beat["error"] = True
helper = str(event.get("helper") or "")
cursor = event.get("cursor")
if helper in CLICK_HELPERS:
if not isinstance(cursor, dict) or cursor.get("x") is None or cursor.get("y") is None:
raise BriefError(f"actions[{index}] identifies a click without captured coordinates")
beat["cursor"] = {"x": cursor["x"], "y": cursor["y"]}
beat["click"] = True
elif helper in TYPE_HELPERS:
box = event.get("box")
if not isinstance(box, dict) or not all(box.get(key) is not None for key in ("x", "y", "w", "h")):
raise BriefError(f"actions[{index}] identifies typing without a captured box")
show_typing = action.get("showTyping") is True
if show_typing and event.get("password"):
raise BriefError(f"actions[{index}].showTyping cannot reveal a password field")
source_line = event.get("sourceLine")
if show_typing and source_line not in revealed_text:
raise BriefError(f"actions[{index}].showTyping requires the original typed event")
beat["type"] = {
"box": {key: box[key] for key in ("x", "y", "w", "h")},
"text": revealed_text[source_line] if show_typing else "••••••",
**({} if show_typing else {"redact": True}),
}
elif action.get("showTyping") is not None:
raise BriefError(f"actions[{index}].showTyping requires a typing event")
target = event_target(event)
if action.get("context") is True and not (beat.get("click") or beat.get("type")):
beat["wide"] = True
elif target and previous_target:
distance = math.hypot(target["x"] - previous_target["x"], target["y"] - previous_target["y"])
diagonal = math.hypot(float(viewport["w"]), float(viewport["h"]))
if distance > diagonal * 0.58:
beat["cameraCut"] = True
ts = event.get("ts")
if isinstance(ts, (int, float)):
beat["t"] = round(max(0.0, float(ts) - first_ts), 3)
beat["dur"] = default_action_duration(beat, pacing)
return beat, target or previous_target
def validate_privacy(reviewed: list[str], redact: dict[str, Any], composition: dict[str, Any]) -> None:
frames = used_frames(composition)
for frame in (*frames, *reviewed, *redact):
if Path(frame).name != frame or not frame.lower().endswith(".jpg"):
raise BriefError(f"invalid frame name: {frame}")
if len(reviewed) != len(set(reviewed)):
raise BriefError("privacy.reviewedFrames contains duplicates")
missing = [frame for frame in frames if frame not in reviewed]
if missing:
raise BriefError("privacy review missing: " + ", ".join(missing))
unknown = sorted(set(redact) - set(frames))
if unknown:
raise BriefError("privacy.redact lists unused frames: " + ", ".join(unknown))
for frame, rectangles in redact.items():
if not isinstance(rectangles, list):
raise BriefError(f"privacy.redact.{frame} must be a list")
for index, rectangle in enumerate(rectangles):
where = f"privacy.redact.{frame}[{index}]"
if not isinstance(rectangle, dict):
raise BriefError(f"{where} must be an object")
reject_unknown(rectangle, REDACTION_KEYS, where)
for key in ("x", "y", "w", "h"):
value = rectangle.get(key)
if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value):
raise BriefError(f"{where}.{key} must be a finite number")
if rectangle["w"] <= 0 or rectangle["h"] <= 0:
raise BriefError(f"{where} must have positive width and height")
for key in ("fill", "stroke"):
value = rectangle.get(key)
if value is not None and value is not False and (
not isinstance(value, str) or not OPAQUE_HEX.fullmatch(value)
):
raise BriefError(f"{where}.{key} must be false or opaque six-digit hex")
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]:
reject_unknown(brief, BRIEF_KEYS, "edit brief")
task = require_text(brief.get("task"), "task")
summary_text = optional_text(brief.get("summary"), "summary")
plan = require_text_list(brief.get("plan"), "plan", 2, 5)
outcomes = require_text_list(brief.get("outcomes"), "outcomes", 1, 5)
actions = brief.get("actions")
if not isinstance(actions, list) or not actions:
raise BriefError("actions must contain at least one action")
events = summary.get("events")
if not isinstance(events, list) or not events:
raise BriefError("recording-summary.json has no events")
first_action = actions[0]
if not isinstance(first_action, dict):
raise BriefError("actions[0] must be an object")
viewport_event = event_at(
events,
first_action.get("frameEvent", first_action.get("event")),
"actions[0].frameEvent" if "frameEvent" in first_action else "actions[0].event",
)
if not (viewport_event.get("viewport") or {}).get("w") or not (viewport_event.get("viewport") or {}).get("h"):
raise BriefError("recording-summary.json has no viewport")
viewport = viewport_event["viewport"]
first_ts = next(
(float(event["ts"]) for event in events if isinstance(event.get("ts"), (int, float))),
0.0,
)
privacy = brief.get("privacy")
if not isinstance(privacy, dict):
raise BriefError("privacy must be an object")
reject_unknown(privacy, PRIVACY_KEYS, "privacy")
reviewed = privacy.get("reviewedFrames")
if not isinstance(reviewed, list) or not all(isinstance(frame, str) for frame in reviewed):
raise BriefError("privacy.reviewedFrames must be a list of frame names")
redact = privacy.get("redact") or {}
if not isinstance(redact, dict):
raise BriefError("privacy.redact must be an object")
explanations = brief.get("explanations") or []
if not isinstance(explanations, list):
raise BriefError("explanations must be a list")
pacing = style["pacing"]
reading_wpm = float(style["readingWpm"])
explanation_by_action: dict[int, list[dict[str, Any]]] = {}
revealed_text = revealed_text or {}
for index, explanation in enumerate(explanations):
if not isinstance(explanation, dict):
raise BriefError(f"explanations[{index}] must be an object")
reject_unknown(explanation, EXPLANATION_KEYS, f"explanations[{index}]")
after_action = explanation.get("afterAction")
if not isinstance(after_action, int) or isinstance(after_action, bool) or not 1 <= after_action <= len(actions):
raise BriefError(f"explanations[{index}].afterAction must index actions")
title = require_text(explanation.get("title"), f"explanations[{index}].title")
sub = optional_text(explanation.get("summary"), f"explanations[{index}].summary")
points = [
{"label": "Observed", "text": require_text(explanation.get("observed"), f"explanations[{index}].observed")},
{"label": "Mistake", "text": require_text(explanation.get("mistake"), f"explanations[{index}].mistake")},
{"label": "Correction", "text": require_text(explanation.get("correction"), f"explanations[{index}].correction")},
]
card = {
"card": True,
"kind": "explanation",
"title": title,
**({"sub": sub} if sub is not None else {}),
"points": points,
"dur": card_duration(
title,
sub,
[part for point in points for part in (point["label"], point["text"])],
"explanation",
reading_wpm,
),
}
explanation_by_action.setdefault(after_action, []).append(card)
intro = {
"card": True,
"kind": "intro",
"title": task,
**({"sub": summary_text} if summary_text is not None else {}),
"dur": card_duration(task, summary_text, plan, "intro", reading_wpm),
}
beats: list[dict[str, Any]] = [intro]
previous_target = None
for index, action in enumerate(actions):
beat, previous_target = compile_action(
action, index, events, plan, first_ts, previous_target, viewport, pacing, revealed_text
)
beats.append(beat)
beats.extend(explanation_by_action.get(index + 1, []))
outcome_title = require_text(brief.get("outcomeTitle") or "Task complete", "outcomeTitle")
outcome_summary = optional_text(brief.get("outcomeSummary"), "outcomeSummary")
beats.append(
{
"card": True,
"kind": "outcome",
"title": outcome_title,
**({"sub": outcome_summary} if outcome_summary is not None else {}),
"outcomes": outcomes,
"dur": card_duration(
outcome_title, outcome_summary, outcomes, "outcome", reading_wpm
),
}
)
validate_narration_cadence(beats)
raw_to_card_count = add_raw_to_card_holds(beats, pacing)
budget = duration_budget(
len(actions), len(explanations), raw_to_card_count, pacing
)
duration = round(sum(float(beat["dur"]) for beat in beats), 3)
if duration > budget + 0.001:
raise BriefError(
f"compiled video is {duration:.1f}s; house-style budget is "
f"{budget:.1f}s. Shorten card copy, remove redundant actions, or set "
"narration only when the thought changes; viewers can pause for detail"
)
house_privacy = style["privacy"]
composition = {
"schemaVersion": style["version"],
"viewport": {"w": viewport["w"], "h": viewport["h"]},
"cursorStart": style["cursorStart"],
"frameStyle": style["frameStyle"],
"readingWpm": style["readingWpm"],
"pacing": pacing,
"durationBudget": budget,
"bg": style["background"],
"plan": plan,
"motion": style["motion"],
"privacy": {
"reviewedFrames": reviewed,
"pad": house_privacy["pad"],
"mask": house_privacy["mask"],
},
"redact": redact,
"beats": beats,
}
validate_privacy(reviewed, redact, composition)
return composition
def write_composition(path: Path, composition: dict[str, Any]) -> None:
body = json.dumps(composition, indent=2, ensure_ascii=False)
path.write_text(f"window.COMPOSITION = {body};\n", encoding="utf-8")
def load_revealed_text(events_path: Path) -> dict[int, str]:
revealed: dict[int, str] = {}
try:
lines = events_path.read_text(encoding="utf-8").splitlines()
except OSError as exc:
raise BriefError(f"cannot read {events_path}: {exc}") from exc
for source_line, line in enumerate(lines, 1):
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError as exc:
raise BriefError(f"cannot read {events_path}: {exc}") from exc
if event.get("helper") in TYPE_HELPERS and event.get("input") != "password":
text = event.get("text")
if text is not None:
revealed[source_line] = str(text)
return revealed
def safe_text(event: dict[str, Any]) -> str | None:
value = event.get("text")
if value is None:
return None
if event.get("helper") in TYPE_HELPERS:
return "<typed text hidden>"
value = str(value)
if event.get("input") == "password" or SENSITIVE.search(value):
return "<sensitive>"
return value[:120]
def safe_label(value: object) -> str | None:
if value is None:
return None
value = str(value)
return "<sensitive>" if SENSITIVE.search(value) else value[:120]
def init_recording(recording: Path, require_explicit: bool = False) -> int:
events_path = recording / "events.jsonl"
if not events_path.is_file():
raise BriefError(f"missing {events_path}")
meta_path = recording / "meta.json"
meta = load_json(meta_path) if meta_path.is_file() else {}
if require_explicit and (not meta_path.is_file() or meta.get("auto") is True):
raise BriefError("not an explicit recording; use the exact path returned by start_recording()")
shutil.copy2(TEMPLATE, recording / "video.html")
events = []
for source_line, line in enumerate(events_path.read_text(encoding="utf-8").splitlines(), 1):
if not line.strip():
continue
try:
raw = json.loads(line)
except json.JSONDecodeError as exc:
raise BriefError(f"cannot read {events_path}: {exc}") from exc
if not raw.get("frame"):
continue
events.append(
{
"frame": raw["frame"],
"sourceLine": source_line,
"helper": raw.get("helper"),
"ts": raw.get("ts"),
"route": "Browser",
"tab": safe_label(raw.get("title")),
"viewport": {"w": raw.get("w"), "h": raw.get("h")},
"cursor": (
{"x": raw.get("x"), "y": raw.get("y")}
if raw.get("x") is not None and raw.get("y") is not None
else None
),
"box": raw.get("box"),
"text": safe_text(raw),
"textLength": len(str(raw.get("text") or "")),
"password": raw.get("input") == "password",
}
)
summary = {
"recording": recording.name,
"title": safe_label(meta.get("title")),
"eventCount": len(events),
"events": events,
}
output = recording / "recording-summary.json"
output.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
write_source_manifest(recording)
print(f"summary: {output}")
print(f"next: write {recording / 'edit-brief.json'}, then run browser-harness video review")
return 0
def run_cli(args: list[str]) -> int:
parser = argparse.ArgumentParser(prog="browser-harness video")
sub = parser.add_subparsers(dest="command", required=True)
init = sub.add_parser("init", help="prepare a recording for editing")
init.add_argument("recording", type=Path)
init.add_argument("--require-explicit", action="store_true")
review = sub.add_parser("review", help="compile and generate a review sheet")
review.add_argument("recording", type=Path)
export = sub.add_parser("export", help="export a reviewed MP4")
export.add_argument("recording", type=Path)
export.add_argument("--output", default="video.mp4")
export.add_argument("--reviewed", action="store_true")
parsed = parser.parse_args(args)
recording = parsed.recording.expanduser().resolve()
try:
if parsed.command == "init":
return init_recording(recording, parsed.require_explicit)
from . import video_render
if parsed.command == "review":
return video_render.review(recording)
return video_render.export(recording, parsed.output, parsed.reviewed)
except (OSError, ValueError, RuntimeError) as exc:
parser.error(str(exc))
def main() -> int:
import sys
return run_cli(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(main())