"""One-command demo recorder + real-model end-to-end validation.
YUI_BRAIN_BACKEND=transformers HF_HUB_DISABLE_XET=1 \
uv run --group train --group viz python scripts/record_demo.py
Boots ONE curated home, runs all four scripted cases through the REAL two-stage
models on CPU, asserts each one's outcome (channel + home-state change), speaks
Yui's reply with the real TTS, and stitches a per-frame "live pipeline trace"
(the home grid + the step indicator + captions, screenshotted from the real app
HTML) into data/video/yui-demo.mp4 — plus a few PNG stills and a short GIF for
the README / social post. No manual screen-recording; nothing mocked.
Renderer: Playwright (faithful to the app's own _render_home_html / _render_stepper)
when chromium is available, else a PIL fallback that draws the same grid + stepper
+ captions. Either way ffmpeg stitches frames + the per-case TTS wav.
This script intentionally drives _render_stepper directly per frame: the live app
streams the stepper mid-handler, but here we own the frame sequence, so we step it
explicitly so the recorded trace visibly progresses
Heard "Hey Yui" -> Listening -> Transcribing -> Routing (stage 1)
-> [Writing automation (stage 2), automation case only]
-> Acting on the home -> Done.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import wave
from dataclasses import dataclass, field
from pathlib import Path
# Ensure the repo root is importable when run as `python scripts/record_demo.py`.
_REPO = Path(__file__).resolve().parent.parent
if str(_REPO) not in sys.path:
sys.path.insert(0, str(_REPO))
import yaml # noqa: E402
# Reuse the app's real rendering + routing helpers — do NOT reimplement them.
import app as appmod # noqa: E402
from voice.home_view import HomeView # noqa: E402
# ---------------------------------------------------------------------------
# Tunable constants (timings/captions live here, per the spec).
# ---------------------------------------------------------------------------
DEMO_HOME = _REPO / "data" / "homes" / "demo-curated.yaml"
OUT_DIR = _REPO / "data" / "video"
FRAMES_DIR = OUT_DIR / "_frames"
MP4_PATH = OUT_DIR / "yui-demo.mp4"
GIF_PATH = OUT_DIR / "yui-demo.gif"
FPS = 30 # output video frame rate
FRAME_W, FRAME_H = 1280, 720 # 16:9, social-friendly
STEP_HOLD_S = 2.2 # how long each pipeline step stays on screen
RESULT_HOLD_S = 4.0 # extra hold on the final (Done) frame so it reads
INTRO_HOLD_S = 2.5 # opening title card
GIF_FPS = 10 # smaller, looping highlight for README/social
def _pick_ffmpeg(name: str) -> str:
"""Prefer a full-featured ffmpeg/ffprobe: a system build with libx264 + a
modern amix beats the miniconda 4.3 build (no x264, old amix). Honor an
explicit override first, then probe candidates, then PATH."""
override = os.environ.get(f"YUI_{name.upper()}")
if override:
return override
candidates = [f"/usr/bin/{name}", shutil.which(name), f"/home/roton/miniconda3/bin/{name}"]
if name == "ffmpeg":
for c in candidates:
if c and Path(c).exists():
try:
enc = subprocess.run([c, "-hide_banner", "-encoders"],
capture_output=True, text=True).stdout
if "libx264" in enc:
return c
except Exception:
continue
for c in candidates:
if c and Path(c).exists():
return c
return name
FFMPEG = _pick_ffmpeg("ffmpeg")
FFPROBE = _pick_ffmpeg("ffprobe")
# A fixed "current time" so the scripted run is deterministic (also keeps the
# automation/time logic stable across re-records).
DEMO_TIME = "7:15 PM on Monday, June 15 2026"
@dataclass
class Case:
key: str
title: str
utterance: str
automation: bool = False # does this case run stage-2?
# filled in at runtime:
spoken: str = ""
passed: bool = False
detail: str = ""
raw_model: str = "" # raw stage-1/stage-2 model output (for reporting)
channel: str = ""
frames: list = field(default_factory=list)
CASES = [
Case("reject", "Case 1 - says no (unknown device)", "open the garage"),
Case("response", "Case 2 - answers a question", "is the front door locked?"),
Case("intent", "Case 3 - turns lights off", "turn off the kitchen light"),
Case(
"automation",
"Case 4 - writes an automation",
"turn on the porch light when motion is detected",
automation=True,
),
]
# ---------------------------------------------------------------------------
# Real pipeline per case (reuses app routing/firing; returns channel + reply +
# a human result line + the assertion verdict). No mocks.
# ---------------------------------------------------------------------------
def _snapshot_states(home: HomeView) -> dict[str, str]:
return {eid: st for eid, (st, _a) in home.home.snapshot().items()}
def run_case(home: HomeView, case: Case, renderer=None) -> None:
"""Run one case through the real models against `home`, mutate `case` with the
channel / spoken reply / PASS-FAIL verdict / raw model output, and a result
line for the caption. Reuses app._summarize_changes / _route_automation paths
by calling the same helpers the app does (stage-1 brain, then channel logic).
If `renderer` is given, a frame is captured at each pipeline step AS the case
runs — so the pre-action steps (Heard … Routing/Writing) show the home before
execution and the Acting/Done steps show it after. This makes the recorded
trace faithful: the grid actually changes when 'Acting on the home' lights up.
"""
from voice.brain import get_brain
def cap(step: str):
if renderer is not None:
_capture_frame(renderer, case, step, home)
devices = home.devices_block()
before = _snapshot_states(home)
# Pre-inference steps share the pre-action home state.
cap(appmod.STEP_HEARD)
cap(appmod.STEP_LISTENING)
cap(appmod.STEP_TRANSCRIBING)
cap(appmod.STEP_ROUTING)
# --- stage 1 (real model, local CPU) ---
result = get_brain().infer(devices, case.utterance, DEMO_TIME)
case.raw_model = repr(result)
if not isinstance(result, dict):
case.channel = "?"
case.detail = f"non-dict result: {result!r}"
case.spoken = "Sorry, something went wrong."
case.passed = False
cap(appmod.STEP_ACTING)
cap(appmod.STEP_DONE)
return
channel = next((k for k in ("intents", "response", "automation") if k in result), "?")
case.channel = channel
if channel == "intents":
cap(appmod.STEP_ACTING)
results = home.execute_intents(result)
summary = appmod._summarize_changes(home, results)
case.detail = summary
case.spoken = "Done." if "No device state changed" in summary else "Okay, done."
elif channel == "response":
cap(appmod.STEP_ACTING)
text = str(result["response"])
case.detail = f"Yui: {text}"
case.spoken = text
elif channel == "automation":
_run_automation(home, case, result, devices, cap)
else:
cap(appmod.STEP_ACTING)
case.detail = f"no known channel in {result!r}"
case.spoken = "Sorry, I didn't understand that."
after = _snapshot_states(home)
case.passed, verdict = _assert_case(case, before, after, channel)
case.detail = (case.detail + "\n\n" + verdict).strip()
cap(appmod.STEP_DONE)
def _run_automation(home: HomeView, case: Case, result: dict, devices: str, cap=None) -> None:
"""Stage-2: write YAML, load it, fire the trigger, animate — mirrors
app._route_automation but without the Gradio plumbing."""
from voice.brain import get_brain2
def _cap(step):
if cap is not None:
cap(step)
auto = result.get("automation")
request = auto.strip() if isinstance(auto, str) and auto.strip() else case.utterance
_cap(appmod.STEP_WRITING) # stage-2 about to run; home still in pre-fire state
body = get_brain2().write(devices, request)
case.raw_model = case.raw_model + "\n\n[stage-2 YAML]\n" + (body or "")
try:
conf = yaml.safe_load(body)
except yaml.YAMLError as exc:
conf = None
case._yaml_err = str(exc) # type: ignore[attr-defined]
if isinstance(conf, list) and len(conf) == 1:
conf = conf[0]
case._conf = conf # type: ignore[attr-defined]
case._yaml = body # type: ignore[attr-defined]
has_action = isinstance(conf, dict) and ("action" in conf or "actions" in conf)
has_trigger = isinstance(conf, dict) and ("trigger" in conf or "triggers" in conf)
if not (has_action and has_trigger):
_cap(appmod.STEP_ACTING)
case.detail = "Drafted YAML but it isn't a valid automation."
case.spoken = "I couldn't build a valid automation."
return
err = home.home.load_automation(conf)
if err:
_cap(appmod.STEP_ACTING)
case.detail = f"Couldn't register: {err}"
case.spoken = "That automation didn't load."
return
_cap(appmod.STEP_ACTING) # registered; about to fire the trigger + animate
fired = appmod._fire_automation(home, conf)
case.detail = "Registered the automation.\n" + fired
case.spoken = "Registered the automation."
def _assert_case(
case: Case, before: dict[str, str], after: dict[str, str], channel: str
) -> tuple[bool, str]:
"""Validate the case's outcome (channel + home-state change). Returns
(passed, human verdict line)."""
changed = {k: (before.get(k), after[k]) for k in after if before.get(k) != after[k]}
if case.key == "reject":
# Should decline via the response channel and NOT actuate a real device.
ok = channel == "response" and not changed
why = (
f"channel={channel} (want response), device changes={changed or 'none'}"
)
return ok, ("PASS - declined, no device touched" if ok else f"FAIL - {why}")
if case.key == "response":
ok = channel == "response"
return ok, (
"PASS - answered on the response channel"
if ok else f"FAIL - landed on {channel}, wanted response"
)
if case.key == "intent":
ok = channel == "intents" and before.get("light.kitchen") == "on" \
and after.get("light.kitchen") == "off"
return ok, (
"PASS - light.kitchen on -> off"
if ok else
f"FAIL - channel={channel}, light.kitchen "
f"{before.get('light.kitchen')} -> {after.get('light.kitchen')}"
)
if case.key == "automation":
conf = getattr(case, "_conf", None)
loaded = isinstance(conf, dict)
ok = (
channel == "automation"
and loaded
and before.get("light.porch") == "off"
and after.get("light.porch") == "on"
)
return ok, (
"PASS - YAML parsed + loaded; light.porch off -> on on motion"
if ok else
f"FAIL - channel={channel}, yaml_parsed={loaded}, light.porch "
f"{before.get('light.porch')} -> {after.get('light.porch')}"
)
return False, "FAIL - unknown case"
# ---------------------------------------------------------------------------
# Frame composition: each frame is the real app HTML (home grid + stepper) wrapped
# in a caption card. We snapshot the home AFTER the case ran, so the "Acting/Done"
# frames show the post-action state (lights flipped) — exactly what the live app
# shows once the .then handler returns.
# ---------------------------------------------------------------------------
_PAGE_CSS = """
* { box-sizing: border-box; }
body {
margin: 0; width: __W__px; height: __H__px;
font-family: 'Quicksand','Segoe UI',system-ui,sans-serif;
color: #2a2540;
background:
radial-gradient(900px 540px at 12% -8%, rgba(196,181,253,.55), transparent 60%),
radial-gradient(820px 500px at 96% 0%, rgba(244,182,255,.50), transparent 60%),
linear-gradient(180deg,#faf5ff 0%,#f5f3ff 55%,#fff 100%);
}
.wrap { padding: 28px 40px; height: 100%; display: flex; flex-direction: column; gap: 16px; }
.title { text-align: center; }
.title h1 {
margin: 0; font-size: 30px;
background: linear-gradient(90deg,#a855f7,#ec4899,#818cf8);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.title .case { font-size: 15px; opacity: .65; margin-top: 2px; }
.stepper-box {
background: rgba(255,255,255,.7); border: 1px solid #ece6fb;
border-radius: 14px; padding: 12px 10px;
}
.home-box {
flex: 1; background: rgba(255,255,255,.78); border: 1px solid #ece6fb;
border-radius: 18px; padding: 14px 20px; overflow: hidden;
}
.home-box h3 { margin: 4px 0 10px; font-size: 20px; }
.caption {
background: rgba(255,255,255,.85); border: 1px solid #ece6fb;
border-radius: 14px; padding: 12px 18px; font-size: 16px; line-height: 1.45;
min-height: 64px;
}
.caption .you { color: #6d28d9; font-weight: 700; }
.caption .yui { color: #be185d; font-weight: 700; }
/* the app's grid uses CSS vars; provide light values so it renders standalone */
:root {
--block-background-fill: #fff;
--border-color-primary: #e7e2f3;
--body-text-color: #2a2540;
}
"""
def _page_css() -> str:
return _PAGE_CSS.replace("__W__", str(FRAME_W)).replace("__H__", str(FRAME_H))
def _frame_html(case: Case, step: str, home: HomeView, caption_html: str) -> str:
# The app HTML carries a 🏠 emoji; this offline chromium has no emoji font, so
# strip it to avoid tofu boxes (purely cosmetic; we don't touch app.py).
home_html = appmod._render_home_html(home).replace("🏠 ", "")
stepper_html = appmod._render_stepper(step)
css = _page_css()
return (
"
"
""
"
"
f"
{stepper_html}
"
f"
{home_html}
"
f"
{caption_html}
"
"
"
)
def _caption(case: Case, step: str) -> str:
you = f"You: “{_esc(case.utterance)}”"
if step == appmod.STEP_DONE and case.spoken:
# Show only the first result line of the detail (keeps the caption tidy),
# stripped of the markdown the app helpers emit (**bold**, leading "- ").
first = case.detail.splitlines()[0] if case.detail else ""
first = first.lstrip("- ").replace("**", "")
yui = f"
Yui: “{_esc(case.spoken)}”"
extra = f"
{_esc(first)}" if first else ""
return you + yui + extra
labels = {
appmod.STEP_HEARD: "Heard the wake word…",
appmod.STEP_LISTENING: "Listening…",
appmod.STEP_TRANSCRIBING: "Transcribing…",
appmod.STEP_ROUTING: "Routing with stage 1…",
appmod.STEP_WRITING: "Writing the automation with stage 2…",
appmod.STEP_ACTING: "Acting on the home…",
}
sub = labels.get(step, "")
return you + (f"
{sub}" if sub else "")
def _esc(s: str) -> str:
import html
return html.escape(s or "")
# ---------------------------------------------------------------------------
# Renderers: Playwright (preferred) or PIL (fallback). Both expose render(html, path).
# ---------------------------------------------------------------------------
class PlaywrightRenderer:
def __init__(self):
from playwright.sync_api import sync_playwright
self._pw = sync_playwright().start()
self._browser = self._pw.chromium.launch()
self._page = self._browser.new_page(
viewport={"width": FRAME_W, "height": FRAME_H},
device_scale_factor=1,
)
def render(self, html: str, path: Path) -> None:
self._page.set_content(html, wait_until="networkidle")
self._page.screenshot(path=str(path), clip={"x": 0, "y": 0, "width": FRAME_W, "height": FRAME_H})
def close(self):
try:
self._browser.close()
self._pw.stop()
except Exception:
pass
class PILRenderer:
"""Fallback: draw the home as a labeled icon grid + the stepper + caption.
Doesn't parse the app HTML; redraws the same information so the frame is clean
even where chromium can't run."""
def __init__(self):
from PIL import ImageFont
self._ImageFont = ImageFont
self._font_lg = self._font(30)
self._font_md = self._font(18)
self._font_sm = self._font(15)
def _font(self, size):
for p in (
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
):
if Path(p).exists():
return self._ImageFont.truetype(p, size)
return self._ImageFont.load_default()
# PIL renderer keeps the case/home/step in render_frame; this signature is
# adapted in build_frames below (it calls a PIL-specific path).
def render(self, html: str, path: Path) -> None: # pragma: no cover - unused path
raise RuntimeError("PILRenderer renders from structured data, not HTML")
def render_data(self, case: Case, step: str, home: HomeView, path: Path) -> None:
from PIL import Image, ImageDraw
img = Image.new("RGB", (FRAME_W, FRAME_H), (250, 245, 255))
d = ImageDraw.Draw(img)
# title
d.text((FRAME_W // 2, 30), "Yui", font=self._font_lg, fill=(168, 85, 247), anchor="mm")
d.text((FRAME_W // 2, 62), case.title, font=self._font_sm, fill=(120, 110, 140), anchor="mm")
# stepper
steps = appmod.STEPS
idx = steps.index(step) if step in steps else -1
x = 40
y = 100
for i, label in enumerate(steps):
mark = "✓ " if i < idx else ""
fill = (236, 72, 153) if i == idx else (
(90, 80, 110) if i < idx else (180, 175, 195)
)
txt = mark + label
d.text((x, y), txt, font=self._font_sm, fill=fill)
w = d.textlength(txt, font=self._font_sm)
x += w + 18
if x > FRAME_W - 160 and i < len(steps) - 1:
x = 40
y += 26
# home grid
gy = y + 44
d.text((40, gy - 6), f"🏠 {home.name}", font=self._font_md, fill=(42, 37, 64))
views = home.render()
cols = 5
cw, ch = (FRAME_W - 80) // cols, 96
for n, v in enumerate(views):
cx = 40 + (n % cols) * cw
cy = gy + 28 + (n // cols) * (ch + 12)
col = tuple(int(v.color.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
d.rounded_rectangle([cx, cy, cx + cw - 12, cy + ch], radius=14,
outline=(231, 226, 243), width=2, fill=(255, 255, 255))
d.ellipse([cx + cw / 2 - 16, cy + 12, cx + cw / 2 + 16, cy + 44],
fill=col if v.active else (200, 200, 210))
d.text((cx + cw / 2 - 6, cy + 58), v.name[:16], font=self._font_sm,
fill=(42, 37, 64), anchor="ma")
d.text((cx + cw / 2 - 6, cy + 76), v.detail[:16], font=self._font_sm,
fill=(140, 135, 160), anchor="ma")
# caption
cap_y = FRAME_H - 96
d.rounded_rectangle([32, cap_y, FRAME_W - 32, FRAME_H - 24], radius=14,
outline=(231, 226, 243), width=2, fill=(255, 255, 255))
d.text((52, cap_y + 14), f"You: “{case.utterance}”",
font=self._font_md, fill=(109, 40, 217))
if step == appmod.STEP_DONE and case.spoken:
d.text((52, cap_y + 44), f"Yui: “{case.spoken}”",
font=self._font_md, fill=(190, 24, 93))
img.save(path)
def close(self):
pass
# ---------------------------------------------------------------------------
# Frame capture: rendered INLINE as each case runs (so pre-action steps show the
# pre-action home and Acting/Done show the post-action home — a faithful trace).
# ---------------------------------------------------------------------------
_frame_idx = 0 # monotonic across the whole run, so filenames stay ordered
def _next_frame_path() -> Path:
global _frame_idx
FRAMES_DIR.mkdir(parents=True, exist_ok=True)
p = FRAMES_DIR / f"f{_frame_idx:05d}.png"
_frame_idx += 1
return p
def _capture_frame(renderer, case: Case, step: str, home: HomeView) -> None:
"""Render one frame for `case` at pipeline `step` from the home's CURRENT live
state, append (step, path) to the case's frame list."""
path = _next_frame_path()
if isinstance(renderer, PILRenderer):
renderer.render_data(case, step, home, path)
else:
html = _frame_html(case, step, home, _caption(case, step))
renderer.render(html, path)
case.frames.append((step, path))
def render_intro(renderer) -> Path:
"""Render the opening title card and return its path."""
path = _next_frame_path()
if isinstance(renderer, PILRenderer):
from PIL import Image, ImageDraw
img = Image.new("RGB", (FRAME_W, FRAME_H), (250, 245, 255))
d = ImageDraw.Draw(img)
d.text((FRAME_W // 2, FRAME_H // 2 - 40), "Yui",
font=renderer._font(64), fill=(168, 85, 247), anchor="mm")
d.text((FRAME_W // 2, FRAME_H // 2 + 30),
"a local two-stage home assistant",
font=renderer._font_md, fill=(120, 110, 140), anchor="mm")
img.save(path)
else:
renderer.render(_intro_html(), path)
return path
def _intro_html() -> str:
css = _page_css()
return (
""
""
"
"
"
Yui
"
"
"
"a local two-stage home assistant — it routes, answers, acts, and "
"writes Home Assistant automations
"
"
"
)
# ---------------------------------------------------------------------------
# TTS per case + audio assembly.
# ---------------------------------------------------------------------------
def _wav_duration(path: str) -> float:
try:
with wave.open(path, "rb") as wf:
return wf.getnframes() / float(wf.getframerate())
except Exception:
return 0.0
def synth_replies(cases: list[Case]) -> dict[str, str]:
"""Synthesize each case's spoken reply. Returns {case.key: wav path or ''}."""
from voice import tts
out = {}
for case in cases:
path = tts.synth(case.spoken) if case.spoken else None
out[case.key] = path or ""
return out
# ---------------------------------------------------------------------------
# ffmpeg stitching. Build a concat of per-frame stills with per-case timing, then
# overlay a per-case audio track aligned to the Done frame.
# ---------------------------------------------------------------------------
def _ffmpeg_codec_args() -> list[str]:
"""Prefer libx264 (portable H.264); fall back to mpeg4 if x264 is missing."""
try:
enc = subprocess.run(
[FFMPEG, "-hide_banner", "-encoders"], capture_output=True, text=True
).stdout
except Exception:
enc = ""
if "libx264" in enc:
return ["-c:v", "libx264", "-pix_fmt", "yuv420p"]
return ["-c:v", "mpeg4", "-q:v", "3", "-pix_fmt", "yuv420p"]
def build_video(cases: list[Case], audio: dict[str, str], intro: Path | None) -> dict:
"""Assemble frames + per-case audio into MP4_PATH. Returns timing info.
Strategy: write a frame-list (ffmpeg concat demuxer) holding each PNG for its
on-screen duration, and a parallel silent/spoken audio timeline so each case's
TTS starts when its Done frame appears. We render the audio timeline as a single
wav (silence + each reply at the right offset) via ffmpeg's adelay+amix, then mux.
"""
OUT_DIR.mkdir(parents=True, exist_ok=True)
# 1) Build the visual concat list with per-frame durations, tracking the
# timeline offset where each case's Done frame begins (for audio sync).
concat_lines = []
t = 0.0
audio_offsets = {} # case.key -> seconds at which its Done frame appears
if intro:
concat_lines.append(f"file '{intro}'")
concat_lines.append(f"duration {INTRO_HOLD_S}")
t += INTRO_HOLD_S
for case in cases:
for step, path in case.frames:
hold = RESULT_HOLD_S if step == appmod.STEP_DONE else STEP_HOLD_S
if step == appmod.STEP_DONE:
audio_offsets[case.key] = t
concat_lines.append(f"file '{path}'")
concat_lines.append(f"duration {hold}")
t += hold
# concat demuxer needs the last file repeated (its final duration is ignored).
last = cases[-1].frames[-1][1]
concat_lines.append(f"file '{last}'")
total = t
concat_txt = FRAMES_DIR / "concat.txt"
concat_txt.write_text("\n".join(concat_lines) + "\n")
codec = _ffmpeg_codec_args()
# 2) Build the audio timeline: each reply delayed to its Done offset, amix'd.
have_audio = [(c.key, audio.get(c.key, ""), audio_offsets.get(c.key, 0.0))
for c in cases if audio.get(c.key)]
audio_path = None
if have_audio:
audio_path = FRAMES_DIR / "track.wav"
inputs = []
filters = []
for i, (_k, wav, off) in enumerate(have_audio):
inputs += ["-i", wav]
ms = int(off * 1000)
filters.append(f"[{i}:a]adelay={ms}|{ms}[a{i}]")
mix = "".join(f"[a{i}]" for i in range(len(have_audio)))
fc = ";".join(filters) + f";{mix}amix=inputs={len(have_audio)}:normalize=0[out]"
cmd = [
FFMPEG, "-hide_banner", "-loglevel", "error", "-y", *inputs,
"-filter_complex", fc, "-map", "[out]",
"-t", f"{total:.3f}", str(audio_path),
]
subprocess.run(cmd, check=True)
# 3) Mux frames (+audio) into the final mp4.
cmd = [
FFMPEG, "-hide_banner", "-loglevel", "error", "-y",
"-f", "concat", "-safe", "0", "-i", str(concat_txt),
]
if audio_path:
cmd += ["-i", str(audio_path)]
cmd += ["-r", str(FPS), *codec]
if audio_path:
cmd += ["-c:a", "aac", "-shortest"]
cmd += [str(MP4_PATH)]
subprocess.run(cmd, check=True)
return {"duration": total, "audio": bool(audio_path)}
def build_gif(cases: list[Case]) -> None:
"""A short looping GIF: the Done frame of each case (the payoff stills)."""
done_frames = []
for c in cases:
done = next((p for s, p in c.frames if s == appmod.STEP_DONE), None)
if done:
done_frames.append(done)
if not done_frames:
return
listfile = FRAMES_DIR / "gif_concat.txt"
lines = []
for p in done_frames:
lines.append(f"file '{p}'")
lines.append("duration 1.6")
lines.append(f"file '{done_frames[-1]}'")
listfile.write_text("\n".join(lines) + "\n")
# palette for a clean GIF at a smaller width.
gw = 720
vf = f"fps={GIF_FPS},scale={gw}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse"
cmd = [
FFMPEG, "-hide_banner", "-loglevel", "error", "-y",
"-f", "concat", "-safe", "0", "-i", str(listfile),
"-vf", vf, "-loop", "0", str(GIF_PATH),
]
subprocess.run(cmd, check=True)
def export_stills(cases: list[Case]) -> list[Path]:
"""Copy 3 representative Done frames out as named PNG stills."""
stills = []
pick = {"intent": "still-intent.png", "automation": "still-automation.png",
"response": "still-response.png"}
for c in cases:
if c.key in pick:
done = next((p for s, p in c.frames if s == appmod.STEP_DONE), None)
if done:
dst = OUT_DIR / pick[c.key]
shutil.copyfile(done, dst)
stills.append(dst)
return stills
# ---------------------------------------------------------------------------
_HOME: HomeView | None = None # the single booted home (spec: boot ONCE)
def main() -> int:
global _HOME
os.environ.setdefault("YUI_BRAIN_BACKEND", "transformers")
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
print("=" * 64)
print("Yui demo recorder — real two-stage pipeline (CPU) + video render")
print("=" * 64)
# Boot ONE home (the harness leaks threads per boot — boot once, run cases
# sequentially against it).
print(f"[boot] loading curated home {DEMO_HOME.name} …", flush=True)
_HOME = HomeView()
_HOME.load(DEMO_HOME)
print(f"[boot] {len(_HOME.render())} devices: "
f"{', '.join(v.entity_id for v in _HOME.render())}", flush=True)
# ---- renderer selection (before the cases, so frames are captured inline as
# each case runs — pre-action steps show the pre-action home, Acting/Done show
# the post-action home: a faithful live trace) ----
if FRAMES_DIR.exists():
shutil.rmtree(FRAMES_DIR)
try:
renderer = PlaywrightRenderer()
renderer_name = "Playwright (chromium)"
except Exception as exc:
print(f"[render] Playwright unavailable ({exc}); falling back to PIL.", flush=True)
renderer = PILRenderer()
renderer_name = "PIL"
print(f"[render] renderer = {renderer_name}", flush=True)
intro_path = render_intro(renderer)
# Run each case through the REAL models, sequentially, asserting outcomes,
# capturing a frame at each pipeline step as it runs.
for case in CASES:
print(f"\n[case] {case.key}: “{case.utterance}”", flush=True)
try:
run_case(_HOME, case, renderer)
except Exception as exc: # never abort the whole run; report loudly
import traceback
case.passed = False
case.detail = f"EXCEPTION: {exc}"
case.raw_model = traceback.format_exc()
verdict = "PASS" if case.passed else "FAIL"
print(f" channel={case.channel} -> {verdict}", flush=True)
for line in case.detail.splitlines():
print(f" {line}", flush=True)
renderer.close()
# ---- validation summary (loud) ----
print("\n" + "=" * 64)
print("VALIDATION SUMMARY")
print("=" * 64)
all_pass = True
for case in CASES:
status = "PASS ✅" if case.passed else "FAIL ❌"
all_pass = all_pass and case.passed
print(f" [{status}] {case.key:11s} channel={case.channel:11s} — {case.title}")
if not case.passed:
print(f" raw model output: {case.raw_model[:400]}")
print("=" * 64)
# ---- TTS ----
print("\n[tts] synthesizing replies …", flush=True)
audio = synth_replies(CASES)
for c in CASES:
a = audio.get(c.key)
print(f" {c.key}: {'wav ' + a + f' ({_wav_duration(a):.1f}s)' if a else 'no audio'}",
flush=True)
nframes = sum(len(c.frames) for c in CASES) + 1
print(f"\n[render] {nframes} frames -> {FRAMES_DIR}", flush=True)
# ---- video + gif + stills ----
print("[ffmpeg] stitching mp4 …", flush=True)
info = build_video(CASES, audio, intro_path)
print("[ffmpeg] building gif …", flush=True)
build_gif(CASES)
stills = export_stills(CASES)
# ---- report artifacts ----
print("\n" + "=" * 64)
print("ARTIFACTS")
print("=" * 64)
if MP4_PATH.exists():
size = MP4_PATH.stat().st_size
dur = _probe_duration(MP4_PATH)
print(f" mp4: {MP4_PATH} ({size/1e6:.2f} MB, {dur:.1f}s, audio={info['audio']})")
else:
print(" mp4: MISSING ❌")
if GIF_PATH.exists():
print(f" gif: {GIF_PATH} ({GIF_PATH.stat().st_size/1e6:.2f} MB)")
for s in stills:
print(f" still: {s} ({s.stat().st_size/1e3:.0f} KB)")
print("=" * 64)
return 0 if all_pass else 1
def _probe_duration(path: Path) -> float:
try:
out = subprocess.run(
[FFPROBE, "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(path)],
capture_output=True, text=True,
).stdout.strip()
return float(out)
except Exception:
return 0.0
if __name__ == "__main__":
sys.exit(main())