Download backend/app/interactive/playback/render_set.py from ruslanmv/HomePilot: direct link, hf CLI and curl.
- Browser
- Download file 12.6 kB
-
https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/backend/app/interactive/playback/render_set.py
- Command line
-
hf download hf://spaces/ruslanmv/HomePilot/backend/app/interactive/playback/render_set.py
-
curl -L -o render_set.py https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/backend/app/interactive/playback/render_set.py
12.6 kB
| """Scene-render target selection for the Interactive pre-render pass. | |
| Additive, best-practice replacement for the naive | |
| ``[n for n in nodes if n.kind in ("scene","decision","assessment","remediation")]`` | |
| filter that ``generator_auto.py`` applies before calling ``render_scene_async``. | |
| Why | |
| --- | |
| The naive filter ignores two facts: | |
| 1. **Persona Live Play owns its own rendering pipeline.** It anchors on the | |
| persona portrait and renders edit-recipe variants per user action. The | |
| scene-graph ``asset_ids[]`` are never read by the Persona Live runtime | |
| (see ``interactive/routes/persona_live.py::_pick_anchor_image_ref``). | |
| Pre-rendering scene nodes for a ``persona_live_play`` project is pure | |
| wasted compute β one SDXL pass per node that nothing ever displays. | |
| 2. **Standard projects rarely need every node rendered before Play opens.** | |
| Only the start node and its first-decision layer need to be ready for | |
| the initial frames. Deeper nodes can be rendered on-demand as the | |
| user's cursor approaches them (industry prefetch pattern). | |
| This module exposes two pure functions with zero I/O side effects: | |
| - ``compute_reachable_nodes(nodes, edges, start_id, max_depth)`` β BFS over | |
| the graph bounded by ``max_depth``. | |
| - ``filter_targets_for_play(targets, experience, edges)`` β takes the | |
| already-computed naive target list and the experience, returns the | |
| subset that actually needs pre-rendering. | |
| Both are side-effect-free and typed so they're trivial to unit-test | |
| without standing up a real experience. | |
| Environment knobs | |
| ----------------- | |
| - ``INTERACTIVE_PRERENDER_DEPTH`` (int, default 2) β BFS depth limit for | |
| Standard projects. Higher = more eager, more compute. Lower = lazier, | |
| faster "Rendering scenes" phase. | |
| - ``INTERACTIVE_PRERENDER_PERSONA_LIVE`` (``true|false``, default ``false``) | |
| β emergency knob that restores the old behavior (pre-render everything | |
| for Persona Live too) if a regression is found. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from dataclasses import dataclass | |
| from typing import Iterable, List, Optional, Sequence, Set | |
| # ββ Configuration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # BFS depth used to decide how many layers of scene/decision nodes to | |
| # pre-render for Standard projects. Default is a large number so existing | |
| # flows pre-render everything (behavior-compatible). Operators opt into | |
| # the depth-limited eager render by setting INTERACTIVE_PRERENDER_DEPTH to | |
| # a small value (e.g. 2 = start + first decision + first choice layer). | |
| DEFAULT_PRERENDER_DEPTH: int = int(os.getenv("INTERACTIVE_PRERENDER_DEPTH", "99") or 99) | |
| # Emergency knob: pre-render everything for Persona Live (old behavior). | |
| PRERENDER_PERSONA_LIVE: bool = ( | |
| os.getenv("INTERACTIVE_PRERENDER_PERSONA_LIVE", "false").lower() == "true" | |
| ) | |
| # Kinds that carry their own visual asset. Endings reuse the trailing frame. | |
| RENDERABLE_KINDS: frozenset[str] = frozenset( | |
| {"scene", "decision", "assessment", "remediation"} | |
| ) | |
| # ββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class _NodeLike: | |
| """Minimal duck-typed view of an interactive Node. | |
| Kept deliberately narrow so this module doesn't depend on the concrete | |
| ORM/dataclass in ``branching/graph.py`` β callers can pass any object | |
| with ``id``, ``kind``, and ``asset_ids`` attributes. | |
| """ | |
| id: str | |
| kind: str | |
| asset_ids: Sequence[str] = () | |
| class _EdgeLike: | |
| """Duck-typed edge view. | |
| Production ``interactive.models.Edge`` uses ``from_node_id`` / | |
| ``to_node_id``. Legacy test doubles (and the shim below) use | |
| ``from_id`` / ``to_id``. ``_edge_endpoints()`` below accepts both β | |
| this dataclass just documents the "old" shape for reference. | |
| """ | |
| from_id: str | |
| to_id: str | |
| def _edge_endpoints(edge: object) -> tuple[str, str]: | |
| """Return ``(from, to)`` for an edge, tolerating both naming styles. | |
| The production ``Edge`` pydantic model exposes ``from_node_id`` / | |
| ``to_node_id``; the test doubles in ``test_interactive_render_set`` | |
| use ``from_id`` / ``to_id``. Returning ``("", "")`` for unrecognised | |
| shapes lets the caller skip the edge without crashing. | |
| """ | |
| fid = ( | |
| getattr(edge, "from_node_id", None) | |
| or getattr(edge, "from_id", None) | |
| or "" | |
| ) | |
| tid = ( | |
| getattr(edge, "to_node_id", None) | |
| or getattr(edge, "to_id", None) | |
| or "" | |
| ) | |
| return str(fid or ""), str(tid or "") | |
| class RenderDecision: | |
| """Why a node was / wasn't selected for pre-render. | |
| Useful for SSE events and debug logs β lets the UI (and operators) | |
| see at a glance why "Rendering scenes 0/6" is actually 0/2 or 0/0. | |
| """ | |
| node_id: str | |
| selected: bool | |
| reason: str | |
| depth: Optional[int] = None | |
| # ββ Core functions βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_reachable_nodes( | |
| nodes: Iterable, | |
| edges: Iterable, | |
| *, | |
| start_id: str, | |
| max_depth: int = DEFAULT_PRERENDER_DEPTH, | |
| ) -> Set[str]: | |
| """BFS from *start_id* up to (inclusive) *max_depth* edges away. | |
| Returns the set of reachable node IDs. The start node itself is | |
| always included at depth 0. Non-existent edges / dangling IDs are | |
| skipped silently β validation is the planner's job, not ours. | |
| A ``max_depth`` of 0 returns only the start node. Negative depths | |
| are clamped to 0. | |
| """ | |
| if not start_id: | |
| return set() | |
| max_depth = max(0, int(max_depth)) | |
| node_ids = {str(getattr(n, "id", "") or "") for n in nodes} | |
| node_ids.discard("") | |
| if start_id not in node_ids: | |
| return set() | |
| # Forward adjacency only β render set cares about what the user | |
| # reaches *from* the start, not what reaches them. | |
| adj: dict[str, List[str]] = {} | |
| for e in edges: | |
| fid, tid = _edge_endpoints(e) | |
| if fid and tid and fid in node_ids and tid in node_ids: | |
| adj.setdefault(fid, []).append(tid) | |
| reachable: Set[str] = {start_id} | |
| frontier: List[tuple[str, int]] = [(start_id, 0)] | |
| while frontier: | |
| nid, depth = frontier.pop(0) | |
| if depth >= max_depth: | |
| continue | |
| for child in adj.get(nid, ()): | |
| if child not in reachable: | |
| reachable.add(child) | |
| frontier.append((child, depth + 1)) | |
| return reachable | |
| def _interaction_type(experience: object) -> str: | |
| """Resolve the interaction flavour, tolerating dict / pydantic shapes. | |
| Production stores the setting in two places depending on surface: | |
| * ``experience.audience_profile["interaction_type"]`` β canonical, | |
| set by the wizard and read by Persona Live routes. | |
| * ``experience.project_type`` β legacy field; a value of | |
| ``"persona_live"`` is the project-level marker. | |
| We also accept a top-level ``interaction_type`` attribute/key so the | |
| existing unit tests (which pass ``{"interaction_type": ...}`` | |
| literals) keep working. Returned value is normalised to match the | |
| legacy ``"persona_live_play"`` sentinel used by the filter. | |
| """ | |
| def _dict_or_none(obj): | |
| if isinstance(obj, dict): | |
| return obj | |
| return None | |
| # 1. Top-level attribute / key (test-friendly, legacy) | |
| if isinstance(experience, dict): | |
| top = str(experience.get("interaction_type") or "").strip() | |
| else: | |
| top = str(getattr(experience, "interaction_type", "") or "").strip() | |
| if top: | |
| return top | |
| # 2. audience_profile.interaction_type (canonical runtime location) | |
| ap = None | |
| if isinstance(experience, dict): | |
| ap = _dict_or_none(experience.get("audience_profile")) | |
| else: | |
| ap = _dict_or_none(getattr(experience, "audience_profile", None)) | |
| if ap: | |
| val = str(ap.get("interaction_type") or "").strip() | |
| if val: | |
| return val | |
| # 3. project_type fallback β "persona_live" implies persona-live play | |
| if isinstance(experience, dict): | |
| pt = str(experience.get("project_type") or "").strip().lower() | |
| else: | |
| pt = str(getattr(experience, "project_type", "") or "").strip().lower() | |
| if pt == "persona_live": | |
| return "persona_live_play" | |
| return "" | |
| def _entry_node_id(experience: object, nodes: Sequence) -> str: | |
| """Best-effort lookup of the start node. | |
| Prefers an explicit ``entry_node_id`` / ``start_node_id`` field on | |
| the experience; falls back to the first scene node. | |
| """ | |
| eid = "" | |
| if isinstance(experience, dict): | |
| eid = str( | |
| experience.get("entry_node_id") | |
| or experience.get("start_node_id") | |
| or "" | |
| ) | |
| else: | |
| eid = str( | |
| getattr(experience, "entry_node_id", "") | |
| or getattr(experience, "start_node_id", "") | |
| or "" | |
| ) | |
| if eid: | |
| return eid | |
| for n in nodes: | |
| if str(getattr(n, "kind", "") or "") == "scene": | |
| return str(getattr(n, "id", "") or "") | |
| return "" | |
| def filter_targets_for_play( | |
| naive_targets: Sequence, | |
| *, | |
| experience: object, | |
| edges: Iterable, | |
| nodes: Optional[Sequence] = None, | |
| max_depth: int = DEFAULT_PRERENDER_DEPTH, | |
| ) -> tuple[List, List[RenderDecision]]: | |
| """Scope the naive target list to what Play will actually display. | |
| Returns ``(targets, decisions)`` where: | |
| * ``targets`` is the filtered list (same objects as ``naive_targets``), | |
| preserving their original order. | |
| * ``decisions`` is one ``RenderDecision`` per *original* target so | |
| callers can emit SSE events for both selected and skipped nodes. | |
| Policy | |
| ------ | |
| 1. ``interaction_type == "persona_live_play"`` β return ``[]``. Persona | |
| Live anchors on the persona portrait and renders edit-recipe variants | |
| on demand; scene-graph assets are never read by its runtime. | |
| Set ``INTERACTIVE_PRERENDER_PERSONA_LIVE=true`` to opt back in. | |
| 2. Otherwise β BFS reachable set from the entry node, bounded by | |
| ``max_depth``. Nodes outside the set are marked ``deferred``; the | |
| on-demand render pass can fill them in when the user cursor lands. | |
| 3. Nodes that already carry ``asset_ids`` are always kept β idempotency | |
| matters more than scope. (The existing generator_auto.py also | |
| skips these during the render loop.) | |
| """ | |
| itype = _interaction_type(experience) | |
| # Persona Live: hand back zero targets. | |
| if itype == "persona_live_play" and not PRERENDER_PERSONA_LIVE: | |
| decisions = [ | |
| RenderDecision( | |
| node_id=str(getattr(n, "id", "") or ""), | |
| selected=False, | |
| reason="persona_live_own_pipeline", | |
| ) | |
| for n in naive_targets | |
| ] | |
| return [], decisions | |
| # Standard: BFS-bounded reachable set. | |
| node_list: Sequence = list(nodes) if nodes is not None else list(naive_targets) | |
| start_id = _entry_node_id(experience, node_list) | |
| reachable: Set[str] = set() | |
| if start_id: | |
| reachable = compute_reachable_nodes( | |
| node_list, edges, start_id=start_id, max_depth=max_depth | |
| ) | |
| selected: List = [] | |
| decisions: List[RenderDecision] = [] | |
| for n in naive_targets: | |
| nid = str(getattr(n, "id", "") or "") | |
| has_assets = bool(getattr(n, "asset_ids", None)) | |
| if has_assets: | |
| selected.append(n) | |
| decisions.append(RenderDecision(nid, True, "already_rendered")) | |
| continue | |
| if not reachable: | |
| # No entry node resolved β fall back to the original behavior. | |
| selected.append(n) | |
| decisions.append(RenderDecision(nid, True, "reachable_unknown")) | |
| continue | |
| if nid in reachable: | |
| selected.append(n) | |
| decisions.append(RenderDecision(nid, True, "reachable")) | |
| else: | |
| decisions.append( | |
| RenderDecision(nid, False, "deferred_unreachable_at_depth") | |
| ) | |
| return selected, decisions | |
| __all__ = [ | |
| "DEFAULT_PRERENDER_DEPTH", | |
| "PRERENDER_PERSONA_LIVE", | |
| "RENDERABLE_KINDS", | |
| "RenderDecision", | |
| "compute_reachable_nodes", | |
| "filter_targets_for_play", | |
| ] | |