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 @@