mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
ee52834701
The demo recorder kept its own copies of the docs site's colors, and they had drifted from `docs/src/styles/custom.css`: - The VHS terminal's ANSI colors were still the Zola site's palette, which #3866 deleted and #3936 replaced with `--wt-terminal-*`. Light yellow had 2.65:1 contrast against the page background; the site's browser test requires 4.5:1 for its own dim terminal text. - `brightWhite`, the background of Worktrunk's gutter, used the ink color, so dark GIFs drew the gutter as a near-white bar. - Zellij, the starship prompt, and Claude Code were configured for light in both themes, so dark GIFs showed a light Zellij tab bar and Claude Code's light theme. `docs/demos/shared/themes.py` now reads the hex `--wt-*` properties from `custom.css` when the build runs. The VHS theme maps ANSI colors the way the site renders snapshot output (`terminal_color_class` and `terminal_background_class` in `tests/integration_tests/readme_sync.rs`). `DemoEnv` carries the recording's theme, so Zellij, the starship palette, and Claude Code follow it. This replaces the `(pre-Starlight; site is #…)` comments the PR first added. Recording from inside a Claude Code session also leaked into the Claude demos. The demo's Claude Code inherited that session's `CLAUDE*` variables and warned that transcript saving was off, and it started Remote Control, which printed a live claude.ai session URL into the GIF. The recorder now drops inherited `CLAUDE*` variables except `CLAUDE_CODE_OAUTH_TOKEN`, and the demo's `settings.json` sets `remoteControlAtStartup` to false. The GIFs are re-recorded locally (`./docs/demos/build docs` and `./docs/demos/build social`) and published in max-sixty/worktrunk-assets@bb3bfba. ## Verification - `pytest docs/demos/tests`: 4 passed. - OCR of every 20th frame of the Claude demos (354 frames) finds no `remote-control`, `claude.ai/code`, `rc active`, or `Transcript saving`. The same check matches on frames from the leaking recording. - Frames checked by eye in both themes for the ANSI colors, the gutter, and the Zellij chrome. - TUI validation passes for all nine Claude demo GIFs. The `wt-switch` and `wt-statusline` checkpoints named absolute frames past the end of these faster recordings (274–318 frames, against 344–465 in the previously published GIFs), so a negative checkpoint frame now counts back from the last frame; the omnibus checkpoint on its closing `wt list` uses the same anchoring. The previously published GIFs pass the new checkpoints too. Those checkpoints also forbid `claude.ai/code` and `Transcript saving`, and a GIF made from a leaking frame fails on them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01TQPDxs2csvGFGBr2j4MtrA > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Maximilian Roos <m@maxroos.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
456 lines
14 KiB
Python
456 lines
14 KiB
Python
"""OCR-based validation for TUI demos.
|
|
|
|
TUI demos (Zellij, interactive UIs) can't be validated via text output because
|
|
VHS only captures the outer terminal, not content rendered inside terminal
|
|
multiplexers. Instead, we extract frames from the GIF and use OCR to verify
|
|
expected content appears.
|
|
|
|
Checkpoints specify a frame range rather than a single frame. The validator
|
|
scans frames within the range (sampling every N frames). Expected patterns must
|
|
share at least one sampled frame, while forbidden patterns must be absent from
|
|
every sampled frame. This makes validation resilient to timing shifts from UI
|
|
changes without allowing transient errors.
|
|
|
|
Usage:
|
|
from shared.validation import validate_tui_demo, TUI_CHECKPOINTS
|
|
|
|
# Validate after building
|
|
errors = validate_tui_demo("wt-zellij-omnibus", gif_path)
|
|
if errors:
|
|
print("Validation failed:", errors)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import tempfile
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
|
|
@dataclass
|
|
class Checkpoint:
|
|
"""A validation checkpoint that scans a range of frames.
|
|
|
|
A negative ``start`` or ``end`` counts back from the GIF's last frame (-1),
|
|
for checkpoints on the state a recording ends in. How many frames a tape
|
|
produces varies between runs with the recording machine's speed, so a
|
|
fixed frame number near the end can fall past a faster recording's end.
|
|
"""
|
|
|
|
start: int
|
|
end: int
|
|
expected: list[str] = field(default_factory=list)
|
|
forbidden: list[str] = field(default_factory=list)
|
|
step: int = 10
|
|
|
|
|
|
# Checkpoint definitions per TUI demo.
|
|
# Ranges are calibrated from actual GIF content, which plays at 25fps.
|
|
# Expected patterns must ALL be present (case-insensitive) in at least one
|
|
# frame within the range. Every forbidden pattern must be absent from every
|
|
# sampled frame.
|
|
|
|
TUI_CHECKPOINTS: dict[str, list[Checkpoint]] = {
|
|
"wt-switch": [
|
|
Checkpoint(
|
|
start=-100,
|
|
end=-1,
|
|
expected=["Claude Code", "Opus", "acme.dashboard"],
|
|
forbidden=[
|
|
"Not logged in",
|
|
"Unknown command",
|
|
"Fable 5 is now",
|
|
"Tackle your toughest",
|
|
# The recorder's own Claude Code session leaking in.
|
|
"claude.ai/code",
|
|
"Transcript saving",
|
|
],
|
|
),
|
|
],
|
|
"wt-statusline": [
|
|
Checkpoint(
|
|
start=-130,
|
|
end=-1,
|
|
expected=["Claude Code", "Opus", "acme.alpha"],
|
|
forbidden=[
|
|
"Not logged in",
|
|
"Unknown command",
|
|
"Fable 5 is now",
|
|
"Tackle your toughest",
|
|
# The recorder's own Claude Code session leaking in.
|
|
"claude.ai/code",
|
|
"Transcript saving",
|
|
],
|
|
),
|
|
],
|
|
"wt-zellij-omnibus": [
|
|
# Claude UI visible on TAB 1 (api) — shows model name and task.
|
|
# Range covers the window where Claude's UI is rendered and stable.
|
|
# Patterns kept minimal (just "Opus" + "acme") since Claude's UI
|
|
# layout shifts across versions — task text may wrap or truncate.
|
|
Checkpoint(
|
|
start=150,
|
|
end=450,
|
|
expected=["Opus", "acme"],
|
|
forbidden=[
|
|
"command not found",
|
|
"Unknown command",
|
|
"Not logged in",
|
|
"Fable 5 is now",
|
|
"Tackle your toughest",
|
|
# The recorder's own Claude Code session leaking in.
|
|
"claude.ai/code",
|
|
"Transcript saving",
|
|
],
|
|
),
|
|
# Claude UI visible on TAB 2 (billing), without referral or model ads.
|
|
Checkpoint(
|
|
start=550,
|
|
end=700,
|
|
expected=["Opus", "billing"],
|
|
forbidden=[
|
|
"Not logged in",
|
|
"Share Claude Code",
|
|
"Fable 5 is now",
|
|
"Tackle your toughest",
|
|
# The recorder's own Claude Code session leaking in.
|
|
"claude.ai/code",
|
|
"Transcript saving",
|
|
],
|
|
),
|
|
# The API agent adds a test. Commit generation should describe that
|
|
# diff rather than replaying the feature-tab fixture's message.
|
|
Checkpoint(
|
|
start=1400,
|
|
end=1750,
|
|
expected=["expand", "coverage"],
|
|
forbidden=["user settings module", "script -q"],
|
|
),
|
|
# The feature push uses a local demo remote internally, but its
|
|
# disposable filesystem path must never appear in the recording.
|
|
Checkpoint(
|
|
start=1000,
|
|
end=1350,
|
|
expected=["Removing feature"],
|
|
forbidden=["/var/folders/", "wt-demo-"],
|
|
),
|
|
# The recording ends on wt list --full showing all worktrees.
|
|
# "billing" omitted: depends on timing of when the branch appears
|
|
# in the list relative to the frame window.
|
|
Checkpoint(
|
|
start=-100,
|
|
end=-1,
|
|
expected=["Branch", "main"],
|
|
forbidden=[
|
|
"CONFLICT",
|
|
"error:",
|
|
"failed",
|
|
"cargo test 2>&1",
|
|
"cargo test -- --list",
|
|
"script -q",
|
|
],
|
|
),
|
|
],
|
|
}
|
|
|
|
|
|
def check_dependencies() -> list[str]:
|
|
"""Check that required tools are available. Returns list of missing tools."""
|
|
missing = []
|
|
for cmd in ["ffmpeg", "tesseract"]:
|
|
result = subprocess.run(
|
|
["which", cmd], capture_output=True, text=True
|
|
)
|
|
if result.returncode != 0:
|
|
missing.append(cmd)
|
|
return missing
|
|
|
|
|
|
def extract_frames(
|
|
gif_path: Path, frames: list[int], out_dir: Path
|
|
) -> dict[int, Path]:
|
|
"""Extract multiple frames from a GIF in a single ffmpeg pass.
|
|
|
|
Returns a mapping from frame number to extracted PNG path.
|
|
"""
|
|
if not frames:
|
|
return {}
|
|
|
|
# Build select filter: select='eq(n,150)+eq(n,160)+eq(n,170)+...'
|
|
select_expr = "+".join(f"eq(n\\,{f})" for f in frames)
|
|
pattern = str(out_dir / "frame_%04d.png")
|
|
|
|
result = subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-loglevel", "error",
|
|
"-i", str(gif_path),
|
|
"-vf", f"select='{select_expr}'",
|
|
"-fps_mode", "vfr",
|
|
str(pattern),
|
|
],
|
|
capture_output=True,
|
|
)
|
|
if result.returncode != 0:
|
|
return {}
|
|
|
|
# ffmpeg numbers output files sequentially (frame_0001.png, frame_0002.png, ...)
|
|
return {
|
|
frame: out_dir / f"frame_{i + 1:04d}.png"
|
|
for i, frame in enumerate(frames)
|
|
if (out_dir / f"frame_{i + 1:04d}.png").exists()
|
|
}
|
|
|
|
|
|
def frame_count(gif_path: Path) -> int:
|
|
"""Number of frames in a GIF."""
|
|
result = subprocess.run(
|
|
[
|
|
"ffprobe",
|
|
"-v", "error",
|
|
"-count_frames",
|
|
"-select_streams", "v:0",
|
|
"-show_entries", "stream=nb_read_frames",
|
|
"-of", "csv=p=0",
|
|
str(gif_path),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
return int(result.stdout)
|
|
|
|
|
|
def ocr_image(image_path: Path) -> str:
|
|
"""Run OCR on an image and return the extracted text."""
|
|
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
|
|
output_base = f.name[:-4] # Remove .txt suffix for tesseract
|
|
|
|
result = subprocess.run(
|
|
["tesseract", str(image_path), output_base, "-l", "eng"],
|
|
capture_output=True,
|
|
)
|
|
|
|
output_path = Path(f"{output_base}.txt")
|
|
if result.returncode == 0 and output_path.exists():
|
|
text = output_path.read_text()
|
|
output_path.unlink()
|
|
return text
|
|
return ""
|
|
|
|
|
|
def ocr_low_contrast_text(image_path: Path) -> str:
|
|
"""Run OCR after lifting dim terminal text to full contrast.
|
|
|
|
Claude renders its model name using a dim ANSI color. The normal OCR pass
|
|
preserves the frame for reliable error detection; this fallback makes dim
|
|
expected text readable without changing the recorded GIF. Frame luminance
|
|
selects whether dim text is lifted from a dark or light background.
|
|
"""
|
|
luma_result = subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-loglevel",
|
|
"error",
|
|
"-i",
|
|
str(image_path),
|
|
"-vf",
|
|
"format=gray,scale=40:30:flags=area,scale=1:1:flags=area",
|
|
"-frames:v",
|
|
"1",
|
|
"-f",
|
|
"rawvideo",
|
|
"pipe:1",
|
|
],
|
|
capture_output=True,
|
|
)
|
|
if luma_result.returncode != 0 or len(luma_result.stdout) != 1:
|
|
return ""
|
|
|
|
frame_luma = luma_result.stdout[0]
|
|
# Terminal background dominates the one-pixel average. Move the cutoff
|
|
# slightly toward the foreground so antialiased dim text becomes solid.
|
|
if frame_luma >= 128:
|
|
threshold = max(0, frame_luma - 24)
|
|
contrast_filter = f"if(lte(val,{threshold}),0,255)"
|
|
else:
|
|
threshold = min(255, frame_luma + 8)
|
|
contrast_filter = f"if(gte(val,{threshold}),255,0)"
|
|
|
|
with tempfile.TemporaryDirectory(prefix="wt-ocr-contrast-") as work_dir:
|
|
enhanced_path = Path(work_dir) / "high-contrast.png"
|
|
result = subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-loglevel",
|
|
"error",
|
|
"-i",
|
|
str(image_path),
|
|
"-vf",
|
|
f"format=gray,lut=y='{contrast_filter}',"
|
|
"scale=iw*3:ih*3:flags=neighbor",
|
|
str(enhanced_path),
|
|
],
|
|
capture_output=True,
|
|
)
|
|
if result.returncode != 0:
|
|
return ""
|
|
return ocr_image(enhanced_path)
|
|
|
|
|
|
def _check_patterns(
|
|
text: str,
|
|
expected: list[str],
|
|
forbidden: list[str],
|
|
) -> tuple[bool, list[str]]:
|
|
"""Check text against expected/forbidden patterns.
|
|
|
|
Returns (passed, errors).
|
|
"""
|
|
text_lower = text.lower()
|
|
errors = []
|
|
|
|
for pattern in expected:
|
|
if pattern.lower() not in text_lower:
|
|
errors.append(f"'{pattern}' not found")
|
|
|
|
for pattern in forbidden:
|
|
if pattern.lower() in text_lower:
|
|
errors.append(f"forbidden '{pattern}' present")
|
|
|
|
return len(errors) == 0, errors
|
|
|
|
|
|
def validate_checkpoint(
|
|
gif_path: Path,
|
|
checkpoint: Checkpoint,
|
|
work_dir: Path,
|
|
) -> tuple[bool, str]:
|
|
"""Validate a checkpoint by scanning its frame range.
|
|
|
|
Extracts all sampled frames in one ffmpeg call, then OCRs each. Expected
|
|
patterns must share a frame; forbidden patterns must be absent throughout
|
|
the range.
|
|
|
|
Returns (passed, detail_message).
|
|
"""
|
|
start, end = checkpoint.start, checkpoint.end
|
|
if start < 0 or end < 0:
|
|
total = frame_count(gif_path)
|
|
start, end = (total + n if n < 0 else n for n in (start, end))
|
|
label = f"frames {start}-{end}"
|
|
frame_numbers = list(range(start, end + 1, checkpoint.step))
|
|
frame_paths = extract_frames(gif_path, frame_numbers, work_dir)
|
|
|
|
if not frame_paths:
|
|
return False, f"failed to extract {label}"
|
|
|
|
best_errors: list[str] = []
|
|
frames_checked = 0
|
|
matched_frame: int | None = None
|
|
|
|
for frame in frame_numbers:
|
|
frame_path = frame_paths.get(frame)
|
|
if frame_path is None:
|
|
continue
|
|
|
|
frames_checked += 1
|
|
text = ocr_image(frame_path)
|
|
if not text:
|
|
continue
|
|
|
|
passed, errors = _check_patterns(text, checkpoint.expected, [])
|
|
if not passed and matched_frame is None:
|
|
low_contrast_text = ocr_low_contrast_text(frame_path)
|
|
if low_contrast_text:
|
|
text = f"{text}\n{low_contrast_text}"
|
|
passed, errors = _check_patterns(
|
|
text, checkpoint.expected, []
|
|
)
|
|
|
|
text_lower = text.lower()
|
|
for pattern in checkpoint.forbidden:
|
|
if pattern.lower() in text_lower:
|
|
return False, f"forbidden '{pattern}' present at frame {frame}"
|
|
|
|
if passed and matched_frame is None:
|
|
matched_frame = frame
|
|
if not best_errors or len(errors) < len(best_errors):
|
|
best_errors = errors
|
|
|
|
if not frames_checked:
|
|
return False, f"no readable frames in {label}"
|
|
if matched_frame is not None:
|
|
return True, f"matched at frame {matched_frame} ({frames_checked} checked)"
|
|
return False, f"no match in {label} ({frames_checked} checked): {'; '.join(best_errors)}"
|
|
|
|
|
|
def validate_tui_demo(demo_name: str, gif_path: Path) -> list[str]:
|
|
"""Validate a TUI demo GIF against its checkpoints.
|
|
|
|
Returns list of error messages. Empty list means validation passed.
|
|
"""
|
|
if demo_name not in TUI_CHECKPOINTS:
|
|
return [f"No checkpoints defined for demo: {demo_name}"]
|
|
|
|
if not gif_path.exists():
|
|
return [f"GIF not found: {gif_path}"]
|
|
|
|
missing = check_dependencies()
|
|
if missing:
|
|
return [f"Missing required tools: {', '.join(missing)}"]
|
|
|
|
checkpoints = TUI_CHECKPOINTS[demo_name]
|
|
all_errors = []
|
|
|
|
with tempfile.TemporaryDirectory(prefix="wt-validate-") as work_dir:
|
|
work_path = Path(work_dir)
|
|
|
|
for checkpoint in checkpoints:
|
|
passed, detail = validate_checkpoint(gif_path, checkpoint, work_path)
|
|
if not passed:
|
|
all_errors.append(detail)
|
|
|
|
return all_errors
|
|
|
|
|
|
def validate_tui_demo_verbose(demo_name: str, gif_path: Path) -> tuple[bool, str]:
|
|
"""Validate a TUI demo with verbose output.
|
|
|
|
Returns (success, output_message).
|
|
"""
|
|
lines = [f"Validating {demo_name}: {gif_path}"]
|
|
|
|
if demo_name not in TUI_CHECKPOINTS:
|
|
return False, f"No checkpoints defined for demo: {demo_name}"
|
|
|
|
if not gif_path.exists():
|
|
return False, f"GIF not found: {gif_path}"
|
|
|
|
missing = check_dependencies()
|
|
if missing:
|
|
return False, f"Missing required tools: {', '.join(missing)}"
|
|
|
|
checkpoints = TUI_CHECKPOINTS[demo_name]
|
|
all_passed = True
|
|
|
|
with tempfile.TemporaryDirectory(prefix="wt-validate-") as work_dir:
|
|
work_path = Path(work_dir)
|
|
|
|
for checkpoint in checkpoints:
|
|
passed, detail = validate_checkpoint(gif_path, checkpoint, work_path)
|
|
label = f"frames {checkpoint.start}-{checkpoint.end}"
|
|
if passed:
|
|
lines.append(f" ✓ {label}: {detail}")
|
|
else:
|
|
lines.append(f" ✗ {label}: {detail}")
|
|
all_passed = False
|
|
|
|
if all_passed:
|
|
lines.append("✓ All checkpoints passed")
|
|
else:
|
|
lines.append("✗ Some checkpoints failed")
|
|
|
|
return all_passed, "\n".join(lines)
|