| """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 |
|
|
| |
| _REPO = Path(__file__).resolve().parent.parent |
| if str(_REPO) not in sys.path: |
| sys.path.insert(0, str(_REPO)) |
|
|
| import yaml |
|
|
| |
| import app as appmod |
| from voice.home_view import HomeView |
|
|
| |
| |
| |
| 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 |
| FRAME_W, FRAME_H = 1280, 720 |
| STEP_HOLD_S = 2.2 |
| RESULT_HOLD_S = 4.0 |
| INTRO_HOLD_S = 2.5 |
| GIF_FPS = 10 |
|
|
| 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") |
|
|
| |
| |
| DEMO_TIME = "7:15 PM on Monday, June 15 2026" |
|
|
|
|
| @dataclass |
| class Case: |
| key: str |
| title: str |
| utterance: str |
| automation: bool = False |
| |
| spoken: str = "" |
| passed: bool = False |
| detail: str = "" |
| raw_model: str = "" |
| 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, |
| ), |
| ] |
|
|
| |
| |
| |
| |
| 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) |
|
|
| |
| cap(appmod.STEP_HEARD) |
| cap(appmod.STEP_LISTENING) |
| cap(appmod.STEP_TRANSCRIBING) |
| cap(appmod.STEP_ROUTING) |
|
|
| |
| 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) |
| 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) |
| if isinstance(conf, list) and len(conf) == 1: |
| conf = conf[0] |
|
|
| case._conf = conf |
| case._yaml = body |
|
|
| 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) |
| 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": |
| |
| 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" |
|
|
|
|
| |
| |
| |
| |
| |
| |
| _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: |
| |
| |
| home_html = appmod._render_home_html(home).replace("π ", "") |
| stepper_html = appmod._render_stepper(step) |
| css = _page_css() |
| return ( |
| "<!doctype html><html><head><meta charset='utf-8'><style>" + css + "</style></head>" |
| "<body><div class='wrap'>" |
| "<div class='title'><h1>Yui</h1>" |
| f"<div class='case'>{case.title}</div></div>" |
| f"<div class='stepper-box'>{stepper_html}</div>" |
| f"<div class='home-box'>{home_html}</div>" |
| f"<div class='caption'>{caption_html}</div>" |
| "</div></body></html>" |
| ) |
|
|
|
|
| def _caption(case: Case, step: str) -> str: |
| you = f"<span class='you'>You:</span> “{_esc(case.utterance)}”" |
| if step == appmod.STEP_DONE and case.spoken: |
| |
| |
| first = case.detail.splitlines()[0] if case.detail else "" |
| first = first.lstrip("- ").replace("**", "") |
| yui = f"<br><span class='yui'>Yui:</span> “{_esc(case.spoken)}”" |
| extra = f"<br><span style='opacity:.6;font-size:14px'>{_esc(first)}</span>" 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"<br><span style='opacity:.6'>{sub}</span>" if sub else "") |
|
|
|
|
| def _esc(s: str) -> str: |
| import html |
|
|
| return html.escape(s or "") |
|
|
|
|
| |
| |
| |
| 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() |
|
|
| |
| |
| def render(self, html: str, path: Path) -> None: |
| 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) |
| |
| 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") |
| |
| 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 |
| |
| 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") |
| |
| 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_idx = 0 |
|
|
|
|
| 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 ( |
| "<!doctype html><html><head><meta charset='utf-8'><style>" + css + "</style></head>" |
| "<body><div class='wrap' style='justify-content:center;align-items:center'>" |
| "<div style='text-align:center'>" |
| "<h1 style='font-size:64px;margin:0;" |
| "background:linear-gradient(90deg,#a855f7,#ec4899,#818cf8);" |
| "-webkit-background-clip:text;background-clip:text;color:transparent'>Yui</h1>" |
| "<p style='font-size:22px;opacity:.7;margin-top:14px'>" |
| "a local two-stage home assistant β it routes, answers, acts, and " |
| "<b>writes Home Assistant automations</b></p>" |
| "</div></div></body></html>" |
| ) |
|
|
|
|
| |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
| |
| 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) |
|
|
| |
| |
| concat_lines = [] |
| t = 0.0 |
| audio_offsets = {} |
| 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 |
| |
| 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() |
|
|
| |
| 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) |
|
|
| |
| 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") |
| |
| 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 |
|
|
|
|
| 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) |
|
|
| |
| |
| 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) |
|
|
| |
| |
| |
| 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) |
|
|
| |
| |
| for case in CASES: |
| print(f"\n[case] {case.key}: β{case.utterance}β", flush=True) |
| try: |
| run_case(_HOME, case, renderer) |
| except Exception as exc: |
| 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() |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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()) |
|
|