diff --git a/integrations/lingbot/README.md b/integrations/lingbot/README.md index 3ccf2147..79983aba 100644 --- a/integrations/lingbot/README.md +++ b/integrations/lingbot/README.md @@ -177,6 +177,60 @@ Then open: - [http://localhost:8089/request_session](http://localhost:8089/request_session) - [http://localhost:8089/healthz](http://localhost:8089/healthz) (`runtime_ready` indicates preload completion) +### Preset assets + +Launch with a local preset directory to replace the initial frame, base prompt, +and text-event catalog without changing the model config: + +```bash +uv run --package flashdreams-lingbot python -m lingbot.webrtc.server \ + --host 0.0.0.0 --port 8089 \ + --config_name lingbot-world-v2-14b-causal-fast-taehv-window15-sink3 \ + --preset-assets-dir \ + integrations/lingbot/lingbot/webrtc/presets/golden-hour-portrait +``` + +Four ready-to-run presets are bundled under `lingbot/webrtc/presets/`: + +- `golden-hour-portrait`: sunlit portrait with subtle character actions. +- `moonlit-portal`: fantasy ruins with portal, light, and weather events. +- `cozy-reading-room`: rainy library with fire, book, and rain events. +- `misty-dinosaur-valley`: prehistoric landscape with wildlife and mist events. + +The Initial Scene panel displays the bundled presets as clickable thumbnails. +Selecting one replaces the frame, prompt, and event catalog for the next WebRTC +session while keeping the model server loaded. Use **Disconnect Session** before +selecting another preset and reconnecting. + +The directory must contain: + +```text +my-preset/ +├── first_frame.png +├── prompt.txt +└── event_texts.json +``` + +`event_texts.json` accepts either a top-level list or an object containing an +`events` or `text_events` list. Each event uses the same schema advertised by +the WebRTC initial-scene payload: + +```json +[ + { + "event_id": "hair-tuck", + "label": "Hair Tuck", + "prompt": "The woman gently tucks a strand of light brown hair behind her ear.", + "category": "portrait" + } +] +``` + +When `--preset-assets-dir` is set, `--example-idx` is ignored and no bundled +example assets are downloaded. Camera intrinsics and poses continue to use the +existing defaults. The launch helper accepts the same directory through the +`PRESET_ASSETS_DIR` environment variable. + ### Runtime requirements - CUDA-capable GPU. diff --git a/integrations/lingbot/lingbot/webrtc/presets/cozy-reading-room/event_texts.json b/integrations/lingbot/lingbot/webrtc/presets/cozy-reading-room/event_texts.json new file mode 100644 index 00000000..b2dc0dc9 --- /dev/null +++ b/integrations/lingbot/lingbot/webrtc/presets/cozy-reading-room/event_texts.json @@ -0,0 +1,20 @@ +[ + { + "event_id": "fire-brightens", + "label": "Fire Brightens", + "prompt": "The fireplace flares gently, casting warmer moving light and soft shadows across the reading room.", + "category": "interior" + }, + { + "event_id": "pages-turn", + "label": "Pages Turn", + "prompt": "A faint breeze turns several pages of the open book on the table.", + "category": "interior" + }, + { + "event_id": "rain-intensifies", + "label": "Rain Intensifies", + "prompt": "Rain begins falling more heavily against the window while droplets trail down the glass.", + "category": "weather" + } +] diff --git a/integrations/lingbot/lingbot/webrtc/presets/cozy-reading-room/first_frame.png b/integrations/lingbot/lingbot/webrtc/presets/cozy-reading-room/first_frame.png new file mode 100644 index 00000000..95b04de0 Binary files /dev/null and b/integrations/lingbot/lingbot/webrtc/presets/cozy-reading-room/first_frame.png differ diff --git a/integrations/lingbot/lingbot/webrtc/presets/cozy-reading-room/prompt.txt b/integrations/lingbot/lingbot/webrtc/presets/cozy-reading-room/prompt.txt new file mode 100644 index 00000000..58cd0cd1 --- /dev/null +++ b/integrations/lingbot/lingbot/webrtc/presets/cozy-reading-room/prompt.txt @@ -0,0 +1 @@ +An empty, cozy old-library reading room glows at night. A deep green armchair sits between a stone fireplace and a rain-streaked window, with an open book on a small wooden table nearby. Warm lamps, dark bookcases, soft curtains, and leafy houseplants create an intimate atmosphere, while amber firelight contrasts with the cool blue rain outside. diff --git a/integrations/lingbot/lingbot/webrtc/presets/golden-hour-portrait/event_texts.json b/integrations/lingbot/lingbot/webrtc/presets/golden-hour-portrait/event_texts.json new file mode 100644 index 00000000..8759c89d --- /dev/null +++ b/integrations/lingbot/lingbot/webrtc/presets/golden-hour-portrait/event_texts.json @@ -0,0 +1,20 @@ +[ + { + "event_id": "hair-tuck", + "label": "Hair Tuck", + "prompt": "The woman gently tucks a strand of light brown hair behind her ear.", + "category": "portrait" + }, + { + "event_id": "subtle-smile", + "label": "Subtle Smile", + "prompt": "A soft, warm smile gradually forms on her face as she looks out the window.", + "category": "portrait" + }, + { + "event_id": "head-turn", + "label": "Head Turn", + "prompt": "She slowly turns her head from the window to look directly at the camera.", + "category": "portrait" + } +] diff --git a/integrations/lingbot/lingbot/webrtc/presets/golden-hour-portrait/first_frame.png b/integrations/lingbot/lingbot/webrtc/presets/golden-hour-portrait/first_frame.png new file mode 100644 index 00000000..f3045293 Binary files /dev/null and b/integrations/lingbot/lingbot/webrtc/presets/golden-hour-portrait/first_frame.png differ diff --git a/integrations/lingbot/lingbot/webrtc/presets/golden-hour-portrait/prompt.txt b/integrations/lingbot/lingbot/webrtc/presets/golden-hour-portrait/prompt.txt new file mode 100644 index 00000000..fda71149 --- /dev/null +++ b/integrations/lingbot/lingbot/webrtc/presets/golden-hour-portrait/prompt.txt @@ -0,0 +1 @@ +A side profile portrait of a young woman with long, golden-brown hair, who is standing bathed in the warm, direct light of the golden hour streaming from a nearby window. Her gaze is directed thoughtfully and serenely out the window, her eyes reflecting the sunlight as she observes the world outside. The powerful, natural light dramatically highlights the strands of her hair and the contours of her face, leaving soft shadows that emphasize her profile. She is wearing a simple, light-colored button-down shirt, adding to the gentle and reflective atmosphere of the moment. The background is a soft-focus interior filled with various potted green plants, which blur into the shadows and create a cozy, organic contrast to the bright light. diff --git a/integrations/lingbot/lingbot/webrtc/presets/misty-dinosaur-valley/event_texts.json b/integrations/lingbot/lingbot/webrtc/presets/misty-dinosaur-valley/event_texts.json new file mode 100644 index 00000000..ce90191f --- /dev/null +++ b/integrations/lingbot/lingbot/webrtc/presets/misty-dinosaur-valley/event_texts.json @@ -0,0 +1,20 @@ +[ + { + "event_id": "dinosaur-raises-head", + "label": "Dinosaur Raises Head", + "prompt": "The long-necked dinosaur slowly raises its head above the mist and looks across the valley.", + "category": "wildlife" + }, + { + "event_id": "flock-crosses-sky", + "label": "Flock Crosses Sky", + "prompt": "A flock of prehistoric flying reptiles glides across the pale dawn sky above the cliffs.", + "category": "wildlife" + }, + { + "event_id": "mist-rolls-in", + "label": "Mist Rolls In", + "prompt": "A thicker bank of cool morning mist rolls along the river and through the fern-covered valley.", + "category": "weather" + } +] diff --git a/integrations/lingbot/lingbot/webrtc/presets/misty-dinosaur-valley/first_frame.png b/integrations/lingbot/lingbot/webrtc/presets/misty-dinosaur-valley/first_frame.png new file mode 100644 index 00000000..df6981d3 Binary files /dev/null and b/integrations/lingbot/lingbot/webrtc/presets/misty-dinosaur-valley/first_frame.png differ diff --git a/integrations/lingbot/lingbot/webrtc/presets/misty-dinosaur-valley/prompt.txt b/integrations/lingbot/lingbot/webrtc/presets/misty-dinosaur-valley/prompt.txt new file mode 100644 index 00000000..53c9f431 --- /dev/null +++ b/integrations/lingbot/lingbot/webrtc/presets/misty-dinosaur-valley/prompt.txt @@ -0,0 +1 @@ +A peaceful prehistoric valley stretches beneath the soft golden light of dawn. A long-necked herbivorous dinosaur stands beside a winding river among low banks of cool mist. Giant ferns frame the foreground, while layered mountains, forested cliffs, a tall waterfall, and distant flying reptiles give the natural-history scene a grand cinematic depth. diff --git a/integrations/lingbot/lingbot/webrtc/presets/moonlit-portal/event_texts.json b/integrations/lingbot/lingbot/webrtc/presets/moonlit-portal/event_texts.json new file mode 100644 index 00000000..c16fe19f --- /dev/null +++ b/integrations/lingbot/lingbot/webrtc/presets/moonlit-portal/event_texts.json @@ -0,0 +1,20 @@ +[ + { + "event_id": "portal-awakens", + "label": "Portal Awakens", + "prompt": "The ancient portal awakens as its cyan runes brighten and a swirling field of light forms within the stone ring.", + "category": "fantasy" + }, + { + "event_id": "fireflies-gather", + "label": "Fireflies Gather", + "prompt": "A cloud of luminous fireflies rises from the ferns and spirals slowly around the portal.", + "category": "fantasy" + }, + { + "event_id": "storm-approaches", + "label": "Storm Approaches", + "prompt": "Dark clouds gather above the ruins as distant lightning briefly illuminates the mountains.", + "category": "weather" + } +] diff --git a/integrations/lingbot/lingbot/webrtc/presets/moonlit-portal/first_frame.png b/integrations/lingbot/lingbot/webrtc/presets/moonlit-portal/first_frame.png new file mode 100644 index 00000000..81f9c217 Binary files /dev/null and b/integrations/lingbot/lingbot/webrtc/presets/moonlit-portal/first_frame.png differ diff --git a/integrations/lingbot/lingbot/webrtc/presets/moonlit-portal/prompt.txt b/integrations/lingbot/lingbot/webrtc/presets/moonlit-portal/prompt.txt new file mode 100644 index 00000000..919050a6 --- /dev/null +++ b/integrations/lingbot/lingbot/webrtc/presets/moonlit-portal/prompt.txt @@ -0,0 +1 @@ +An ancient circular stone portal stands among moss-covered ruins at blue hour. Dim cyan runes trace the weathered ring while a wet stone path winds through dense ferns toward it. Moonlit mist drifts between broken columns, waterfalls, and distant mountains, creating a mysterious cinematic fantasy landscape with deep layers and realistic natural detail. diff --git a/integrations/lingbot/lingbot/webrtc/server.py b/integrations/lingbot/lingbot/webrtc/server.py index 10c83f13..c8a84a72 100644 --- a/integrations/lingbot/lingbot/webrtc/server.py +++ b/integrations/lingbot/lingbot/webrtc/server.py @@ -22,7 +22,10 @@ import json import os from contextlib import ExitStack +from dataclasses import dataclass +from functools import lru_cache from importlib.resources import as_file, files +from pathlib import Path from typing import Protocol, cast import torch @@ -56,13 +59,64 @@ LingbotRuntimeConfig, LingbotSessionInput, LingbotWebRTCSessionManager, + TextEventSpec, normalize_prompt_text, normalize_text_events, ) WEB_DIR_RESOURCE = files("lingbot.webrtc").joinpath("web") +PRESET_DIR_RESOURCE = files("lingbot.webrtc").joinpath("presets") MAX_UPLOAD_IMAGE_BYTES = 15 * 1024 * 1024 MAX_PROMPT_CHARS = 2_000 +PRESET_FIRST_FRAME_FILENAME = "first_frame.png" +PRESET_PROMPT_FILENAME = "prompt.txt" +PRESET_TEXT_EVENTS_FILENAME = "event_texts.json" +PRESET_ASSET_FILENAMES = ( + PRESET_FIRST_FRAME_FILENAME, + PRESET_PROMPT_FILENAME, + PRESET_TEXT_EVENTS_FILENAME, +) +"""Required filenames in a WebRTC preset-assets directory.""" + +BUNDLED_PRESET_IDS = ( + "golden-hour-portrait", + "moonlit-portal", + "cozy-reading-room", + "misty-dinosaur-valley", +) +"""Stable UI order for bundled WebRTC presets.""" + + +@dataclass(frozen=True, slots=True) +class PresetAsset: + """In-memory assets for one selectable WebRTC scene preset.""" + + preset_id: str + """Stable identifier submitted by the WebRTC client.""" + + label: str + """Short display label for the preset picker.""" + + prompt: str + """Base scene prompt.""" + + first_frame: LingbotImagePayload + """Initial scene image.""" + + text_events: tuple[TextEventSpec, ...] + """Text-driven events available for the scene.""" + + def as_public_dict(self) -> dict[str, str]: + """Return metadata needed to render a preset picker card.""" + return { + "preset_id": self.preset_id, + "label": self.label, + "first_frame_url": f"/api/presets/{self.preset_id}/first_frame", + } + + +PRESET_CATALOG_KEY = web.AppKey("lingbot_preset_catalog", dict[str, PresetAsset]) +"""Application key for the immutable bundled-preset lookup.""" class LingbotSessionManager(WebRTCSessionManager, Protocol): @@ -71,10 +125,43 @@ def get_first_frame(self) -> LingbotImagePayload: ... def set_pending_session_input(self, session_input: LingbotSessionInput) -> None: ... +class _PresetResource(Protocol): + """Readable interface shared by package resources and local paths.""" + + def joinpath( + self, + *descendants: str | os.PathLike[str], + ) -> _PresetResource: ... + + def is_file(self) -> bool: ... + def read_bytes(self) -> bytes: ... + def read_text( + self, + encoding: str | None = None, + errors: str | None = None, + ) -> str: ... + + def _get_lingbot_manager(app: web.Application) -> LingbotSessionManager: return cast(LingbotSessionManager, app[SESSION_MANAGER_KEY]) +def _get_preset_catalog(app: web.Application) -> dict[str, PresetAsset]: + return app[PRESET_CATALOG_KEY] + + +def _initial_scene_payload( + app: web.Application, + manager: LingbotSessionManager, +) -> dict[str, object]: + payload = dict(manager.get_initial_scene()) + payload.setdefault("active_preset_id", None) + payload["presets"] = [ + preset.as_public_dict() for preset in _get_preset_catalog(app).values() + ] + return payload + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( @@ -141,6 +228,16 @@ def parse_args() -> argparse.Namespace: choices=EXAMPLE_DATA_AVAILABLE_IDXS, help="Example folder index under the LingBot example-data cache.", ) + parser.add_argument( + "--preset-assets-dir", + "--preset_assets_dir", + type=Path, + default=None, + help=( + "Directory containing first_frame.png, prompt.txt, and " + "event_texts.json. Overrides --example-idx and skips example downloads." + ), + ) return parser.parse_args() @@ -165,7 +262,14 @@ def create_app( ) app.router.add_get("/api/session/initial_scene", _initial_scene) app.router.add_get("/api/session/first_frame", _first_frame) + app.router.add_get( + "/api/presets/{preset_id}/first_frame", + _preset_first_frame, + ) app.router.add_post("/api/session/input", _session_input) + app[PRESET_CATALOG_KEY] = { + preset.preset_id: preset for preset in load_bundled_presets() + } app["package_resource_stack"] = resource_stack app.on_cleanup.append(_close_package_resources) except Exception: @@ -176,7 +280,7 @@ def create_app( async def _initial_scene(request: web.Request) -> web.StreamResponse: manager = _get_lingbot_manager(request.app) - return web.json_response(manager.get_initial_scene()) + return web.json_response(_initial_scene_payload(request.app, manager)) async def _first_frame(request: web.Request) -> web.StreamResponse: @@ -187,6 +291,17 @@ async def _first_frame(request: web.Request) -> web.StreamResponse: return web.Response(body=payload.data, content_type=payload.content_type) +async def _preset_first_frame(request: web.Request) -> web.StreamResponse: + preset_id = request.match_info["preset_id"] + preset = _get_preset_catalog(request.app).get(preset_id) + if preset is None: + raise web.HTTPNotFound(reason=f"Unknown Lingbot preset: {preset_id}") + return web.Response( + body=preset.first_frame.data, + content_type=preset.first_frame.content_type, + ) + + async def _read_upload_bytes(field: BodyPartReader) -> bytes: data = bytearray() while True: @@ -203,6 +318,7 @@ async def _read_upload_bytes(field: BodyPartReader) -> bytes: async def _session_input(request: web.Request) -> web.StreamResponse: + preset_id: str | None = None prompt: str | None = None image_bytes: bytes | None = None image_url: str | None = None @@ -223,6 +339,9 @@ async def _session_input(request: web.Request) -> web.StreamResponse: break if not isinstance(field, BodyPartReader): continue + if field.name == "preset_id": + preset_id = (await field.text()).strip() or None + continue if field.name == "prompt": prompt = normalize_prompt_text(await field.text()) if len(prompt) > MAX_PROMPT_CHARS: @@ -258,9 +377,12 @@ async def _session_input(request: web.Request) -> web.StreamResponse: ) else: form = await request.post() + preset_id_raw = form.get("preset_id") prompt_raw = form.get("prompt") image_url_raw = form.get("image_url") text_events_raw = form.get("text_events", form.get("events")) + if isinstance(preset_id_raw, str): + preset_id = preset_id_raw.strip() or None if isinstance(prompt_raw, str): prompt = normalize_prompt_text(prompt_raw) if len(prompt) > MAX_PROMPT_CHARS: @@ -277,6 +399,19 @@ async def _session_input(request: web.Request) -> web.StreamResponse: reason="Text events must be valid JSON." ) from exc + preset = None + if preset_id is not None: + preset = _get_preset_catalog(request.app).get(preset_id) + if preset is None: + raise web.HTTPBadRequest(reason=f"Unknown Lingbot preset: {preset_id}") + if prompt is None: + prompt = preset.prompt + if image_bytes is None and image_url is None: + image_bytes = preset.first_frame.data + image_content_type = preset.first_frame.content_type + if text_events is None: + text_events = preset.text_events + if image_bytes is not None: image_url = None @@ -307,6 +442,7 @@ async def _session_input(request: web.Request) -> web.StreamResponse: first_frame_image_url=image_url, first_frame_content_type=image_content_type, text_events=normalized_text_events, + preset_id=preset.preset_id if preset is not None else None, ) try: await asyncio.to_thread(manager.set_pending_session_input, session_input) @@ -314,7 +450,7 @@ async def _session_input(request: web.Request) -> web.StreamResponse: raise web.HTTPConflict(reason=str(exc)) from exc except ValueError as exc: raise web.HTTPBadRequest(reason=str(exc)) from exc - return web.json_response(manager.get_initial_scene()) + return web.json_response(_initial_scene_payload(request.app, manager)) def build_runtime_config( @@ -327,14 +463,26 @@ def build_runtime_config( raise ValueError("--video-height and --video-width must be > 0") if args.video_height % 16 != 0 or args.video_width % 16 != 0: raise ValueError("--video-height and --video-width must be divisible by 16") - example_idx = getattr(args, "example_idx", 0) - example_dir = EXAMPLE_DATA_DIR_LOCAL / example_data_dirname(example_idx) - if ( - example_idx == 0 - and not example_dir.exists() - and (EXAMPLE_DATA_DIR_LOCAL / "image.jpg").exists() - ): - example_dir = EXAMPLE_DATA_DIR_LOCAL + preset_assets_dir = getattr(args, "preset_assets_dir", None) + if preset_assets_dir is not None: + example_dir, text_events = load_preset_assets(preset_assets_dir) + first_frame_filename = PRESET_FIRST_FRAME_FILENAME + default_image_url = None + default_preset_id = _bundled_preset_id_for_path(example_dir) + else: + example_idx = getattr(args, "example_idx", 0) + example_dir = EXAMPLE_DATA_DIR_LOCAL / example_data_dirname(example_idx) + if ( + example_idx == 0 + and not example_dir.exists() + and (EXAMPLE_DATA_DIR_LOCAL / "image.jpg").exists() + ): + example_dir = EXAMPLE_DATA_DIR_LOCAL + default_runtime_config = LingbotRuntimeConfig() + text_events = default_runtime_config.text_events + first_frame_filename = "image.jpg" + default_image_url = default_runtime_config.default_image_url + default_preset_id = None return LingbotRuntimeConfig( config_name=args.config_name, compile_network=not args.no_compile, @@ -345,9 +493,118 @@ def build_runtime_config( video_height=args.video_height, video_width=args.video_width, example_data_dir=example_dir, + first_frame_filename=first_frame_filename, + default_image_url=default_image_url, + default_preset_id=default_preset_id, + text_events=text_events, ) +def _read_preset_assets( + preset_assets_dir: _PresetResource, + *, + source: str, +) -> tuple[bytes, str, tuple[TextEventSpec, ...]]: + missing = [ + filename + for filename in PRESET_ASSET_FILENAMES + if not preset_assets_dir.joinpath(filename).is_file() + ] + if missing: + raise ValueError(f"{source} is missing: {', '.join(missing)}") + + first_frame_path = preset_assets_dir.joinpath(PRESET_FIRST_FRAME_FILENAME) + try: + first_frame_bytes = first_frame_path.read_bytes() + except OSError as exc: + raise ValueError( + f"Failed to read preset first frame from {first_frame_path}: {exc}" + ) from exc + if not first_frame_bytes.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError(f"Preset first frame is not a PNG file: {first_frame_path}") + + prompt_path = preset_assets_dir.joinpath(PRESET_PROMPT_FILENAME) + try: + prompt = normalize_prompt_text(prompt_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError) as exc: + raise ValueError( + f"Failed to read preset prompt from {prompt_path}: {exc}" + ) from exc + + events_path = preset_assets_dir.joinpath(PRESET_TEXT_EVENTS_FILENAME) + try: + raw_events: object = json.loads(events_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError( + f"Failed to read preset text events from {events_path}: {exc}" + ) from exc + + if isinstance(raw_events, dict): + raw_events = raw_events.get("text_events", raw_events.get("events")) + if raw_events is None: + raise ValueError( + f"Preset text events in {events_path} must be a list or contain " + "an 'events' or 'text_events' list." + ) + try: + text_events = normalize_text_events(raw_events) + except ValueError as exc: + raise ValueError(f"Invalid preset text events in {events_path}: {exc}") from exc + return first_frame_bytes, prompt, text_events + + +def load_preset_assets( + preset_assets_dir: Path, +) -> tuple[Path, tuple[TextEventSpec, ...]]: + """Validate a preset-assets directory and load its text-event catalog.""" + preset_assets_dir = preset_assets_dir.expanduser().resolve() + if not preset_assets_dir.is_dir(): + raise ValueError( + f"--preset-assets-dir must be an existing directory: {preset_assets_dir}" + ) + _, _, text_events = _read_preset_assets( + preset_assets_dir, + source=f"Preset assets directory {preset_assets_dir}", + ) + return preset_assets_dir, text_events + + +@lru_cache(maxsize=1) +def load_bundled_presets() -> tuple[PresetAsset, ...]: + """Load bundled preset assets for the WebRTC picker.""" + presets = [] + for preset_id in BUNDLED_PRESET_IDS: + preset_dir = cast( + _PresetResource, + PRESET_DIR_RESOURCE.joinpath(preset_id), + ) + first_frame_bytes, prompt, text_events = _read_preset_assets( + preset_dir, + source=f"Bundled preset {preset_id}", + ) + presets.append( + PresetAsset( + preset_id=preset_id, + label=preset_id.replace("-", " ").title(), + prompt=prompt, + first_frame=LingbotImagePayload( + data=first_frame_bytes, + content_type="image/png", + ), + text_events=text_events, + ) + ) + return tuple(presets) + + +def _bundled_preset_id_for_path(preset_assets_dir: Path) -> str | None: + preset_id = preset_assets_dir.name + if preset_id not in BUNDLED_PRESET_IDS: + return None + bundled_path = Path(__file__).resolve().parent / "presets" / preset_id + return preset_id if preset_assets_dir == bundled_path.resolve() else None + + def initialize_distributed( *, default_device: str | torch.device = "cuda:0" ) -> tuple[torch.device, int, int]: @@ -407,17 +664,13 @@ def main() -> None: default_device=args.device ) - # Pull the bundled example-data assets onto rank 0 (and barrier the - # rest) before constructing the session manager: the manager's - # initial-sync step checks the example_data_dir for the first frame - # / intrinsics / poses / prompt files and raises FileNotFoundError - # otherwise. Mirrors the offline runner's pre-flight behavior so the - # WebRTC entry point is launchable on a fresh checkout with no - # manual file staging. - ensure_example_data_downloaded( - is_rank_zero=(world_rank == 0), - example_idx=args.example_idx, - ) + # Without a local preset, mirror the offline runner's example-data + # preflight so the WebRTC entry point works on a fresh checkout. + if getattr(args, "preset_assets_dir", None) is None: + ensure_example_data_downloaded( + is_rank_zero=(world_rank == 0), + example_idx=args.example_idx, + ) runtime_config = build_runtime_config( args, diff --git a/integrations/lingbot/lingbot/webrtc/session.py b/integrations/lingbot/lingbot/webrtc/session.py index dec3c746..b7fb0fbb 100644 --- a/integrations/lingbot/lingbot/webrtc/session.py +++ b/integrations/lingbot/lingbot/webrtc/session.py @@ -450,14 +450,17 @@ class LingbotRuntimeConfig: default_prompt: str = "" """Prompt used when the selected example does not provide ``prompt.txt``.""" default_image_url: str | None = _DEFAULT_IMAGE_URL + default_preset_id: str | None = None + """Bundled preset represented by the default scene, when applicable.""" default_intrinsics_url: str | None = _DEFAULT_INTRINSICS_URL default_poses_url: str | None = _DEFAULT_POSES_URL warmup_chunks: int = 10 warmup_timeout_s: float = 600.0 example_data_dir: Path = field( - default_factory=lambda: default_flashdreams_cache_dir() - / "example_data/lingbot_world" + default_factory=lambda: ( + default_flashdreams_cache_dir() / "example_data/lingbot_world" + ) ) first_frame_filename: str = "image.jpg" intrinsics_filename: str = "intrinsics.npy" @@ -476,6 +479,9 @@ class LingbotImagePayload: @dataclass(frozen=True, slots=True) class LingbotSessionInput: + preset_id: str | None = None + """Bundled preset selected as the base for this session input.""" + prompt: str | None = None first_frame_image_bytes: bytes | None = None first_frame_image_url: str | None = None @@ -876,8 +882,7 @@ def _resolve_world_scale(self) -> float: def _load_default_prompt(self) -> str: prompt_path = self.config.example_data_dir / self.config.prompt_filename if prompt_path.exists(): - with prompt_path.open("r", encoding="utf-8") as handle: - prompt = normalize_prompt_text(handle.readline()) + prompt = normalize_prompt_text(prompt_path.read_text(encoding="utf-8")) if prompt: return prompt prompt = normalize_prompt_text(self.config.default_prompt) @@ -955,8 +960,6 @@ def _prepare_session_input_state( if session_input is not None and session_input.prompt is not None else self._load_default_prompt() ) - if not prompt: - raise ValueError("Lingbot prompt is empty.") if session_input is not None and session_input.first_frame_image_bytes: image_rgb = self._load_uploaded_first_frame_rgb( @@ -1190,11 +1193,21 @@ def get_initial_scene(self) -> dict[str, object]: if pending_input is not None and pending_input.prompt is not None else self._runtime._load_default_prompt() ) - if pending_input is not None and pending_input.first_frame_image_url: - image_url = pending_input.first_frame_image_url - else: - image_url = self.runtime_config.default_image_url - input_source = "uploaded" if pending_input is not None else "default" + image_url = ( + pending_input.first_frame_image_url + if pending_input is not None + else self.runtime_config.default_image_url + ) + input_source = ( + "preset" + if pending_input is not None and pending_input.preset_id is not None + else ("uploaded" if pending_input is not None else "default") + ) + active_preset_id = ( + pending_input.preset_id + if pending_input is not None + else self.runtime_config.default_preset_id + ) first_frame_path = ( self.runtime_config.example_data_dir / self.runtime_config.first_frame_filename @@ -1221,6 +1234,7 @@ def get_initial_scene(self) -> dict[str, object]: "capabilities": {"text_events": bool(text_events)}, "event_catalog": [event.as_public_dict() for event in text_events], "active_event_id": getattr(self._runtime, "_active_event_id", None), + "active_preset_id": active_preset_id, "resolution": { "width": self.runtime_config.video_width, "height": self.runtime_config.video_height, @@ -1317,6 +1331,11 @@ def set_pending_session_input(self, session_input: LingbotSessionInput) -> None: else (current.text_events if current is not None else None) ) self._pending_session_input = LingbotSessionInput( + preset_id=( + session_input.preset_id + if session_input.preset_id is not None + else (current.preset_id if current is not None else None) + ), prompt=( normalize_prompt_text(session_input.prompt) if session_input.prompt is not None diff --git a/integrations/lingbot/lingbot/webrtc/web/request_session.css b/integrations/lingbot/lingbot/webrtc/web/request_session.css index f11e4309..d9433405 100644 --- a/integrations/lingbot/lingbot/webrtc/web/request_session.css +++ b/integrations/lingbot/lingbot/webrtc/web/request_session.css @@ -272,6 +272,78 @@ body[data-status="generating"] .connectButton { display: none; } +.presetPicker { + display: grid; + gap: 7px; +} + +.presetPicker[hidden] { + display: none; +} + +.presetHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: var(--muted); + font-size: 0.76rem; + font-weight: 700; +} + +.presetList { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.presetButton { + min-width: 0; + padding: 0; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 8px; + background: rgba(4, 6, 7, 0.72); + color: var(--text); + cursor: pointer; + text-align: left; +} + +.presetButton:hover:not(:disabled), +.presetButton:focus-visible { + border-color: rgba(142, 240, 28, 0.68); + outline: 0; +} + +.presetButton.is-active { + border-color: var(--accent); + box-shadow: 0 0 0 1px rgba(142, 240, 28, 0.28); +} + +.presetButton:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.presetThumbnail { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + background: rgba(255, 255, 255, 0.06); +} + +.presetLabel { + display: block; + min-height: 36px; + padding: 7px 8px; + overflow: hidden; + font-size: 0.72rem; + font-weight: 800; + line-height: 1.2; + text-overflow: ellipsis; +} + .firstFrameSourceRow { display: grid; grid-template-columns: 86px minmax(0, 1fr) 86px; @@ -832,6 +904,10 @@ body[data-status="generating"] .statusLine strong { width: auto; } + .presetList { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .controlCard, .logCard { left: 18px; diff --git a/integrations/lingbot/lingbot/webrtc/web/request_session.html b/integrations/lingbot/lingbot/webrtc/web/request_session.html index 08806494..4345fafa 100644 --- a/integrations/lingbot/lingbot/webrtc/web/request_session.html +++ b/integrations/lingbot/lingbot/webrtc/web/request_session.html @@ -51,6 +51,13 @@

Lingbot WebRTC Viewer

Initial Scene +
diff --git a/integrations/lingbot/lingbot/webrtc/web/request_session.js b/integrations/lingbot/lingbot/webrtc/web/request_session.js index 4178a73d..846839f2 100644 --- a/integrations/lingbot/lingbot/webrtc/web/request_session.js +++ b/integrations/lingbot/lingbot/webrtc/web/request_session.js @@ -22,6 +22,9 @@ const remoteVideo = document.getElementById("remoteVideo") const mockCanvas = document.getElementById("mockCanvas") const firstFramePreview = document.getElementById("firstFramePreview") const sceneCard = document.getElementById("sceneCard") +const presetPicker = document.getElementById("presetPicker") +const presetList = document.getElementById("presetList") +const presetStatus = document.getElementById("presetStatus") const firstFrameSourceRow = document.getElementById("firstFrameSourceRow") const uploadModeButton = document.getElementById("uploadModeButton") const urlModeButton = document.getElementById("urlModeButton") @@ -67,6 +70,7 @@ let mockGenerationStarted = false let mockChunkTimer = null let actionStarted = false let initialSceneLocked = false +let presetSelectionInFlight = false let promptEdited = false let textEventsEdited = false let firstFrameUrlEdited = false @@ -193,6 +197,9 @@ function setInitialSceneLocked(locked) { firstFrameUrlUpdateButton.disabled = locked promptInput.disabled = locked addTextEventButton.disabled = locked + for (const button of presetList.querySelectorAll("button")) { + button.disabled = locked || presetSelectionInFlight + } for (const input of textEventList.querySelectorAll("input, textarea, button")) { input.disabled = locked } @@ -209,6 +216,14 @@ function setFirstFrameInputMode(mode) { } function defaultFirstFrameName() { + const activePresetId = initialScene && initialScene.active_preset_id + const presets = Array.isArray(initialScene && initialScene.presets) + ? initialScene.presets + : [] + const activePreset = presets.find((preset) => preset.preset_id === activePresetId) + if (activePreset && activePreset.label) { + return String(activePreset.label) + } return initialScene && initialScene.has_first_frame ? "Example Image" : "Choose Image" } @@ -256,6 +271,91 @@ function refreshedPreviewUrl(url) { return `${url}${separator}t=${firstFramePreviewRefreshToken}` } +function setPresetStatus(message = "", state = "idle") { + presetStatus.textContent = message + presetStatus.dataset.state = state +} + +function renderPresetPicker() { + const presets = Array.isArray(initialScene && initialScene.presets) + ? initialScene.presets + : [] + presetPicker.hidden = presets.length === 0 + presetList.replaceChildren() + for (const preset of presets) { + const presetId = String(preset.preset_id || "").trim() + if (!presetId) { + continue + } + const label = String(preset.label || presetId) + const button = document.createElement("button") + button.className = "presetButton" + button.type = "button" + button.dataset.presetId = presetId + button.disabled = initialSceneLocked || presetSelectionInFlight + button.classList.toggle("is-active", initialScene.active_preset_id === presetId) + button.setAttribute("aria-pressed", initialScene.active_preset_id === presetId ? "true" : "false") + button.setAttribute("aria-label", `Use ${label} preset`) + + const thumbnail = document.createElement("img") + thumbnail.className = "presetThumbnail" + thumbnail.src = String(preset.first_frame_url || "") + thumbnail.alt = "" + thumbnail.loading = "lazy" + + const caption = document.createElement("span") + caption.className = "presetLabel" + caption.textContent = label + button.append(thumbnail, caption) + button.addEventListener("click", () => { + void selectPreset(presetId, label) + }) + presetList.append(button) + } +} + +async function selectPreset(presetId, label) { + if (initialSceneLocked || presetSelectionInFlight || mockMode) { + return + } + presetSelectionInFlight = true + setPresetStatus("Loading...", "pending") + renderPresetPicker() + + try { + const form = new FormData() + form.append("preset_id", presetId) + const response = await fetch("/api/session/input", { + method: "POST", + body: form, + }) + if (!response.ok) { + const text = (await response.text()).trim().replace(/^\d+:\s*/, "") + throw new Error(text || `preset selection failed (${response.status})`) + } + + clearSelectedFirstFrameFile() + clearFirstFrameUrlInput() + setFirstFrameInputMode("url") + firstFrameSelectionCommitted = false + promptEdited = false + textEventsEdited = false + firstFrameUrlEdited = false + applyInitialScene(await response.json()) + setPresetStatus("Selected", "success") + logEvent(`selected preset: ${label}`, { source: "client" }) + } catch (error) { + setPresetStatus(error.message, "error") + logEvent(`preset selection failed: ${error.message}`, { + source: "client", + level: "error", + }) + } finally { + presetSelectionInFlight = false + renderPresetPicker() + } +} + function updateReadyPreview() { const canPreview = !document.body.classList.contains("has-video") const hasSelectedImage = selectedFirstFrameUrl !== null && firstFrameSelectionCommitted @@ -283,7 +383,11 @@ function applyInitialScene(scene) { } const sceneImageUrl = typeof scene.image_url === "string" ? scene.image_url - : (typeof scene.default_image_url === "string" ? scene.default_image_url : "") + : ( + scene.input_source === "default" && typeof scene.default_image_url === "string" + ? scene.default_image_url + : "" + ) if (!selectedFirstFrameFile && !firstFrameUrlEdited && sceneImageUrl) { firstFrameUrlInput.value = sceneImageUrl setFirstFrameInputMode("url") @@ -304,6 +408,7 @@ function applyInitialScene(scene) { } } activeEventId = scene.active_event_id || null + renderPresetPicker() if (!textEventsEdited) { setTextEventDraftsFromCatalog(scene.event_catalog) } @@ -1001,6 +1106,13 @@ function disconnectSession({ notify = true } = {}) { stopStatsPolling() connected = false actionStarted = false + inferenceInFlight = false + mockGenerationStarted = false + releaseAllKeys() + setInitialSceneLocked(false) + setStatus("Idle", "idle") + setFlow("waiting") + connectButton.textContent = mockMode ? "Start Mock Session" : "Connect Session" updateReadyPreview() connectButton.disabled = false if (notify && controlChannel && controlChannel.readyState === "open") { @@ -1016,6 +1128,8 @@ function disconnectSession({ notify = true } = {}) { if (peerConnection) { peerConnection.close() } + remoteVideo.srcObject = null + setVideoVisible(false) } async function connectSession() { @@ -1067,6 +1181,8 @@ async function connectSession() { logEvent(`connection_state=${state}`, { source: "client" }) if (state === "connected") { connected = true + connectButton.disabled = false + connectButton.textContent = "Disconnect Session" setStatus("Waiting", "waiting") setFlow("connected; waiting for input") startStatsPolling() @@ -1079,8 +1195,10 @@ async function connectSession() { if (["failed", "closed", "disconnected"].includes(state)) { connected = false actionStarted = false + setInitialSceneLocked(false) updateReadyPreview() connectButton.disabled = false + connectButton.textContent = "Connect Session" stopHeartbeat() stopStatsPolling() setStatus(state === "failed" ? "Error" : "Idle", state === "failed" ? "error" : "idle") @@ -1369,12 +1487,15 @@ async function startMockSession() { setStatus("Connecting", "connecting") setFlow("mock warmup") logEvent("connecting to mock server...", { source: "client" }) + disconnecting = false actionStarted = false await uploadSessionInputIfNeeded() await new Promise((resolve) => { window.setTimeout(resolve, 260) }) connected = true + connectButton.disabled = false + connectButton.textContent = "Disconnect Session" metrics.targetFps = 16 metrics.resolution = "1280x720" metrics.model = "lingbot-world-v2-14b-causal-fast-taehv-window15-sink3" @@ -1516,7 +1637,11 @@ function initialize() { } connectButton.addEventListener("click", () => { - void connectSession() + if (connected) { + disconnectSession() + } else { + void connectSession() + } }) clearEventButton.addEventListener("click", () => { sendTextEvent(activeEventId || "clear", "clear") diff --git a/integrations/lingbot/pyproject.toml b/integrations/lingbot/pyproject.toml index f9e39cd9..6bdea26d 100644 --- a/integrations/lingbot/pyproject.toml +++ b/integrations/lingbot/pyproject.toml @@ -62,6 +62,11 @@ exclude = ["tests"] "*.js", "assets/*.svg", ] +"lingbot.webrtc" = [ + "presets/*/first_frame.png", + "presets/*/prompt.txt", + "presets/*/event_texts.json", +] [tool.uv] managed = true diff --git a/integrations/lingbot/scripts/launch_webrtc.sh b/integrations/lingbot/scripts/launch_webrtc.sh index 391919c7..263be75b 100755 --- a/integrations/lingbot/scripts/launch_webrtc.sh +++ b/integrations/lingbot/scripts/launch_webrtc.sh @@ -13,6 +13,12 @@ FPS="${FPS:-16}" EXAMPLE_IDX="${EXAMPLE_IDX:-0}" VIDEO_HEIGHT="${VIDEO_HEIGHT:-352}" VIDEO_WIDTH="${VIDEO_WIDTH:-640}" +PRESET_ASSETS_DIR="${PRESET_ASSETS_DIR:-}" + +PRESET_ARGS=() +if [[ -n "${PRESET_ASSETS_DIR}" ]]; then + PRESET_ARGS+=(--preset-assets-dir "${PRESET_ASSETS_DIR}") +fi cd "$(dirname "${BASH_SOURCE[0]}")/../../.." @@ -25,4 +31,5 @@ exec uv run --no-sync python -m lingbot.webrtc.server \ --fps "${FPS}" \ --video-height "${VIDEO_HEIGHT}" \ --video-width "${VIDEO_WIDTH}" \ - --example-idx "${EXAMPLE_IDX}" + --example-idx "${EXAMPLE_IDX}" \ + "${PRESET_ARGS[@]}" diff --git a/integrations/lingbot/tests/test_preset_assets.py b/integrations/lingbot/tests/test_preset_assets.py new file mode 100644 index 00000000..628f1110 --- /dev/null +++ b/integrations/lingbot/tests/test_preset_assets.py @@ -0,0 +1,187 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CPU checks for LingBot WebRTC preset assets.""" + +from __future__ import annotations + +import json +import sys +from argparse import Namespace +from pathlib import Path + +import pytest +from lingbot.webrtc import server + +pytestmark = pytest.mark.ci_cpu + + +def _write_preset(directory: Path, *, wrapped_events: bool = False) -> None: + """Write a minimal valid preset-assets directory.""" + directory.mkdir() + (directory / server.PRESET_FIRST_FRAME_FILENAME).write_bytes(b"\x89PNG\r\n\x1a\n") + (directory / server.PRESET_PROMPT_FILENAME).write_text( + "A quiet portrait by a sunny window.\n", + encoding="utf-8", + ) + events: object = [ + { + "event_id": "smile", + "label": "Smile", + "prompt": "A warm smile forms on her face.", + "category": "portrait", + } + ] + if wrapped_events: + events = {"events": events} + (directory / server.PRESET_TEXT_EVENTS_FILENAME).write_text( + json.dumps(events), + encoding="utf-8", + ) + + +def _server_args(preset_assets_dir: Path | None) -> Namespace: + """Build WebRTC CLI arguments without parsing process arguments.""" + return Namespace( + config_name="lingbot-world-v2-14b-causal-fast", + no_compile=True, + device="cuda:0", + warmup_chunks=0, + warmup_timeout_s=600.0, + video_height=352, + video_width=640, + example_idx=0, + preset_assets_dir=preset_assets_dir, + ) + + +@pytest.mark.parametrize("wrapped_events", [False, True]) +def test_load_preset_assets_accepts_supported_event_layouts( + tmp_path: Path, + wrapped_events: bool, +) -> None: + """Load direct and object-wrapped event catalogs.""" + preset_dir = tmp_path / "preset" + _write_preset(preset_dir, wrapped_events=wrapped_events) + + resolved_dir, events = server.load_preset_assets(preset_dir) + + assert resolved_dir == preset_dir.resolve() + assert [event.event_id for event in events] == ["smile"] + assert events[0].prompt == "A warm smile forms on her face." + + +def test_load_preset_assets_reports_missing_files(tmp_path: Path) -> None: + """List every required asset missing from an incomplete preset.""" + preset_dir = tmp_path / "preset" + preset_dir.mkdir() + + with pytest.raises( + ValueError, match="first_frame.png, prompt.txt, event_texts.json" + ): + server.load_preset_assets(preset_dir) + + +def test_build_runtime_config_uses_preset_assets(tmp_path: Path) -> None: + """Override the default example frame and event catalog with a preset.""" + preset_dir = tmp_path / "preset" + _write_preset(preset_dir) + + config = server.build_runtime_config(_server_args(preset_dir)) + + assert config.example_data_dir == preset_dir.resolve() + assert config.first_frame_filename == "first_frame.png" + assert config.prompt_filename == "prompt.txt" + assert config.default_image_url is None + assert config.default_preset_id is None + assert [event.event_id for event in config.text_events] == ["smile"] + + +def test_build_runtime_config_identifies_bundled_default_preset() -> None: + """Mark a bundled launch preset as selected in initial-scene metadata.""" + preset_dir = ( + Path(server.__file__).resolve().parent / "presets" / "golden-hour-portrait" + ) + + config = server.build_runtime_config(_server_args(preset_dir)) + + assert config.default_preset_id == "golden-hour-portrait" + + +def test_parse_args_accepts_preset_assets_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Expose the preset directory through both CLI spellings.""" + monkeypatch.setattr( + sys, + "argv", + ["lingbot.webrtc.server", "--preset-assets-dir", str(tmp_path)], + ) + + args = server.parse_args() + + assert args.preset_assets_dir == tmp_path + + +def test_bundled_preset_catalog_exposes_picker_metadata() -> None: + """Load all bundled presets in their stable UI order.""" + presets = server.load_bundled_presets() + + assert [preset.preset_id for preset in presets] == list(server.BUNDLED_PRESET_IDS) + assert all(preset.prompt for preset in presets) + assert all(preset.first_frame.data.startswith(b"\x89PNG") for preset in presets) + assert all(preset.text_events for preset in presets) + assert presets[0].as_public_dict() == { + "preset_id": "golden-hour-portrait", + "label": "Golden Hour Portrait", + "first_frame_url": "/api/presets/golden-hour-portrait/first_frame", + } + + +@pytest.mark.parametrize( + ("preset_name", "expected_event_ids"), + [ + ( + "golden-hour-portrait", + ["hair-tuck", "subtle-smile", "head-turn"], + ), + ( + "moonlit-portal", + ["portal-awakens", "fireflies-gather", "storm-approaches"], + ), + ( + "cozy-reading-room", + ["fire-brightens", "pages-turn", "rain-intensifies"], + ), + ( + "misty-dinosaur-valley", + ["dinosaur-raises-head", "flock-crosses-sky", "mist-rolls-in"], + ), + ], +) +def test_bundled_preset_is_valid( + preset_name: str, + expected_event_ids: list[str], +) -> None: + """Keep every bundled preset synchronized with the asset schema.""" + preset_dir = Path(server.__file__).resolve().parent / "presets" / preset_name + + resolved_dir, events = server.load_preset_assets(preset_dir) + + assert resolved_dir == preset_dir + assert (preset_dir / "first_frame.png").read_bytes().startswith(b"\x89PNG") + assert (preset_dir / "prompt.txt").read_text(encoding="utf-8").strip() + assert [event.event_id for event in events] == expected_event_ids diff --git a/integrations/lingbot/tests/test_server_routes.py b/integrations/lingbot/tests/test_server_routes.py index 23e65cad..38368fa9 100644 --- a/integrations/lingbot/tests/test_server_routes.py +++ b/integrations/lingbot/tests/test_server_routes.py @@ -79,7 +79,8 @@ def set_pending_session_input(self, session_input: LingbotSessionInput) -> None: **self.initial_scene, "prompt": session_input.prompt or self.initial_scene["prompt"], "event_catalog": event_catalog, - "input_source": "uploaded", + "input_source": "preset" if session_input.preset_id else "uploaded", + "active_preset_id": session_input.preset_id, } async def preload_runtime(self) -> None: @@ -223,7 +224,11 @@ async def test_initial_scene_route_returns_preview_metadata() -> None: response = await client.get("/api/session/initial_scene") payload = await response.json() assert response.status == 200 - assert payload == manager.initial_scene + assert payload["prompt"] == manager.initial_scene["prompt"] + assert payload["active_preset_id"] is None + assert [preset["preset_id"] for preset in payload["presets"]] == list( + lingbot_server.BUNDLED_PRESET_IDS + ) finally: await client.close() @@ -242,6 +247,68 @@ async def test_first_frame_route_serves_manager_image() -> None: await client.close() +@pytest.mark.asyncio +async def test_bundled_preset_preview_route_serves_png() -> None: + manager = FakeSessionManager() + client = await _build_client(manager) + try: + response = await client.get("/api/presets/golden-hour-portrait/first_frame") + body = await response.read() + + assert response.status == 200 + assert response.headers["Content-Type"] == "image/png" + assert body.startswith(b"\x89PNG\r\n\x1a\n") + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_session_input_selects_bundled_preset() -> None: + manager = FakeSessionManager() + client = await _build_client(manager) + try: + form = FormData() + form.add_field("preset_id", "moonlit-portal") + + response = await client.post("/api/session/input", data=form) + payload = await response.json() + + assert response.status == 200 + assert payload["active_preset_id"] == "moonlit-portal" + assert payload["input_source"] == "preset" + assert len(manager.pending_inputs) == 1 + session_input = manager.pending_inputs[0] + assert session_input.preset_id == "moonlit-portal" + assert session_input.prompt is not None and "portal" in session_input.prompt + assert session_input.first_frame_image_bytes is not None + assert session_input.first_frame_image_bytes.startswith(b"\x89PNG\r\n\x1a\n") + assert session_input.first_frame_content_type == "image/png" + assert session_input.text_events is not None + assert [event.event_id for event in session_input.text_events] == [ + "portal-awakens", + "fireflies-gather", + "storm-approaches", + ] + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_session_input_rejects_unknown_preset() -> None: + manager = FakeSessionManager() + client = await _build_client(manager) + try: + form = FormData() + form.add_field("preset_id", "missing-preset") + + response = await client.post("/api/session/input", data=form) + + assert response.status == 400 + assert manager.pending_inputs == [] + finally: + await client.close() + + @pytest.mark.asyncio async def test_blocking_session_routes_use_worker_thread( monkeypatch: pytest.MonkeyPatch, diff --git a/integrations/lingbot/tests/test_webrtc_runtime.py b/integrations/lingbot/tests/test_webrtc_runtime.py index e3ac8f65..2245312a 100644 --- a/integrations/lingbot/tests/test_webrtc_runtime.py +++ b/integrations/lingbot/tests/test_webrtc_runtime.py @@ -231,6 +231,7 @@ def _load_default_prompt(self) -> str: assert scene["capabilities"] == {"text_events": True} assert scene["active_event_id"] is None + assert scene["active_preset_id"] is None assert scene["event_catalog"] == [ event.as_public_dict() for event in session.DEFAULT_TEXT_EVENTS ] @@ -291,6 +292,40 @@ def _load_default_prompt(self) -> str: assert scene["event_catalog"] == [custom_events[0].as_public_dict()] +def test_initial_scene_tracks_selected_preset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Advertise the default and pending preset selections.""" + + class _FakeRuntime: + _active_event_id = None + + def __init__(self, config: LingbotRuntimeConfig) -> None: + self.config = config + + def _load_default_prompt(self) -> str: + return "drive through a city" + + monkeypatch.setattr(session, "LingbotInferenceRuntime", _FakeRuntime) + manager = LingbotWebRTCSessionManager( + runtime_config=LingbotRuntimeConfig( + device="cpu", + warmup_chunks=0, + default_preset_id="golden-hour-portrait", + ) + ) + + assert manager.get_initial_scene()["active_preset_id"] == "golden-hour-portrait" + + manager.set_pending_session_input( + session.LingbotSessionInput(preset_id="moonlit-portal") + ) + + scene = manager.get_initial_scene() + assert scene["active_preset_id"] == "moonlit-portal" + assert scene["input_source"] == "preset" + + def test_pending_remote_first_frame_is_fetched_once_and_cached( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -400,6 +435,36 @@ def _fail_remote_fetch(image_url: str) -> object: assert runtime._prompt == "follow a coastal highway" +def test_prepare_session_input_state_accepts_empty_prompt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Encode an empty base prompt when preset ``prompt.txt`` is empty.""" + runtime = session.LingbotInferenceRuntime( + config=LingbotRuntimeConfig(device="cpu", warmup_chunks=0) + ) + runtime._device = torch.device("cpu") + encoded_texts: list[tuple[str, ...]] = [] + + monkeypatch.setattr(runtime, "_load_default_prompt", lambda: "") + monkeypatch.setattr(runtime, "_load_default_first_frame_rgb", object) + monkeypatch.setattr( + runtime, + "_first_frame_to_tensor", + lambda image_rgb: torch.zeros((1, 3, 2, 2)), + ) + + def _encode(texts: list[str]) -> torch.Tensor: + encoded_texts.append(tuple(texts)) + return torch.zeros((len(texts), 1, 2)) + + monkeypatch.setattr(runtime, "_encode_text_embeddings_sync", _encode) + + runtime._prepare_session_input_state(None) + + assert runtime._prompt == "" + assert encoded_texts == [("",)] + + @pytest.mark.asyncio async def test_event_message_dispatches_to_runtime_and_acknowledges( monkeypatch: pytest.MonkeyPatch,