diff --git a/src/odyssey/_internal/simulations.py b/src/odyssey/_internal/simulations.py index 490ce8f..636c3b4 100644 --- a/src/odyssey/_internal/simulations.py +++ b/src/odyssey/_internal/simulations.py @@ -26,6 +26,28 @@ def __init__(self, auth: AuthClient, api_url: str, debug: bool = False) -> None: self._debug = debug self._http_session: aiohttp.ClientSession | None = None + def _auto_append_end(self, script: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Auto-append end entry if missing (ergonomic improvement). + + The API requires scripts to end with an 'end' action, but users often forget. + Rather than throwing a cryptic server error, we silently append it. + + Args: + script: List of script entries. + + Returns: + Script with end entry appended if missing. + """ + if not script: + return script + + last_entry = script[-1] + if "end" not in last_entry: + last_timestamp = last_entry.get("timestamp_ms", 0) + return [*script, {"timestamp_ms": last_timestamp + 3000, "end": {}}] + + return script + def _log(self, msg: str) -> None: """Log a debug message.""" if self._debug: @@ -84,9 +106,9 @@ async def submit_job( if script_url: body["script_url"] = script_url elif script: - body["script"] = script + body["script"] = self._auto_append_end(script) elif scripts: - body["scripts"] = scripts + body["scripts"] = [self._auto_append_end(s) for s in scripts] session = await self._get_http_session() headers = await self._get_auth_headers() diff --git a/src/odyssey/client.py b/src/odyssey/client.py index 38dc62f..6defee4 100644 --- a/src/odyssey/client.py +++ b/src/odyssey/client.py @@ -6,7 +6,7 @@ import logging from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, overload from ._internal import AuthClient, RecordingsClient, SignalingClient, SimulationsClient, WebRTCConnection from ._internal.webrtc import WebRTCCallbacks @@ -766,87 +766,107 @@ async def _ensure_simulations_client(self) -> None: debug=self._config.dev.debug, ) + @overload async def simulate( self, + prompts: list[str | dict[str, Any]], + *, + portrait: bool = True, + interval: int = 3000, + ) -> SimulationJobDetail: ... + + @overload + async def simulate( + self, + prompts: None = None, *, script: list[dict[str, Any]] | None = None, scripts: list[list[dict[str, Any]]] | None = None, script_url: str | None = None, portrait: bool = True, + ) -> SimulationJobDetail: ... + + async def simulate( + self, + prompts: list[str | dict[str, Any]] | None = None, + *, + script: list[dict[str, Any]] | None = None, + scripts: list[list[dict[str, Any]]] | None = None, + script_url: str | None = None, + portrait: bool = True, + interval: int = 3000, ) -> SimulationJobDetail: """Submit a simulation job to be processed asynchronously. - Simulation jobs generate video without requiring an active WebRTC connection. - The video is generated server-side and can be retrieved once complete. + Supports two calling styles: - Images in script entries can be provided as: - - File path (str): Local path to an image file - - Raw bytes: Image file contents as bytes - - PIL Image: A PIL/Pillow Image object - - NumPy array: RGB uint8 array with shape (H, W, 3) - - Base64 data URL (str): Already encoded (data:image/...;base64,...) + **Simple (ergonomic):** Pass a list of prompts. The first prompt starts the video, + subsequent prompts are interactions spaced by `interval` (default 3000ms). - The client automatically converts all formats to base64 before sending. + **Full control:** Pass keyword arguments with explicit script entries and timestamps. Note: This method can be called without an active connection. It only requires a valid API key. Args: - script: Single script to run. List of script entries with: - - timestamp_ms: Time in milliseconds - - start: {"prompt": str, "image": } - - interact: {"prompt": str} - - end: {} + prompts: Simple mode - list of prompt strings (or dicts with prompt/image). + First prompt starts the video, subsequent prompts are interactions. + script: Full control - single script with explicit timestamps. scripts: Batch mode - multiple scripts to run in parallel. - script_url: URL to script JSON file (alternative to script/scripts). + script_url: URL to script JSON file. portrait: True for portrait (704x1280), False for landscape (1280x704). + interval: Time between prompts in simple mode (default 3000ms). Returns: SimulationJobDetail with job info including job_id. Raises: - ValueError: If invalid script options provided or unsupported image format. + ValueError: If invalid options provided. ConnectionError: If API request fails. Example: - # Text-to-video simulation - job = await client.simulate( - script=[ - {"timestamp_ms": 0, "start": {"prompt": "A cat sleeping"}}, - {"timestamp_ms": 5000, "interact": {"prompt": "The cat wakes up"}}, - {"timestamp_ms": 10000, "end": {}}, - ] - ) + # Simple: list of prompts (recommended) + job = await client.simulate(["A cat sleeping", "The cat wakes up", "The cat stretches"]) - # Image-to-video with file path + # Simple with options job = await client.simulate( - script=[ - {"timestamp_ms": 0, "start": {"prompt": "Robot dancing", "image": "/path/to/image.jpg"}}, - {"timestamp_ms": 10000, "end": {}}, - ] + ["A cat sleeping", "The cat yawns"], + portrait=False, + interval=5000, ) - # Image-to-video with PIL Image - from PIL import Image - img = Image.open("photo.jpg") - job = await client.simulate( - script=[ - {"timestamp_ms": 0, "start": {"prompt": "Animate this", "image": img}}, - {"timestamp_ms": 10000, "end": {}}, - ] - ) + # Simple with image-to-video (first entry as dict) + job = await client.simulate([ + {"prompt": "A robot dancing", "image": "/path/to/image.jpg"}, + "The robot spins", + "The robot bows", + ]) - # Image-to-video with numpy array (e.g., from OpenCV) - import cv2 - frame = cv2.imread("photo.jpg") - frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + # Full control: explicit timestamps job = await client.simulate( script=[ - {"timestamp_ms": 0, "start": {"prompt": "Animate this", "image": frame_rgb}}, + {"timestamp_ms": 0, "start": {"prompt": "A cat sleeping"}}, + {"timestamp_ms": 5000, "interact": {"prompt": "The cat wakes up"}}, {"timestamp_ms": 10000, "end": {}}, ] ) """ + # Handle simple prompt list form + if prompts is not None: + script = [] + for i, entry in enumerate(prompts): + timestamp_ms = i * interval + if i == 0: + # First entry is 'start' + if isinstance(entry, str): + script.append({"timestamp_ms": timestamp_ms, "start": {"prompt": entry}}) + else: + script.append({"timestamp_ms": timestamp_ms, "start": entry}) + else: + # Subsequent entries are 'interact' + prompt = entry if isinstance(entry, str) else entry.get("prompt", "") + script.append({"timestamp_ms": timestamp_ms, "interact": {"prompt": prompt}}) + await self._ensure_simulations_client() # Process scripts to convert image file paths to base64 @@ -911,6 +931,13 @@ async def _process_simulation_script(self, script: list[dict[str, Any]], portrai # Pass through interact/end/start-without-image without modification processed.append(entry) + # Auto-append end entry if missing (ergonomic improvement) + # The API requires scripts to end with an 'end' action, but users often forget. + # Rather than throwing a cryptic server error, we silently append it. + if processed and "end" not in processed[-1]: + last_timestamp = processed[-1].get("timestamp_ms", 0) + processed.append({"timestamp_ms": last_timestamp + 3000, "end": {}}) + return processed def _encode_image(self, image: Any, portrait: bool) -> str: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d287025 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Odyssey SDK test suite.""" diff --git a/tests/test_simulate.py b/tests/test_simulate.py new file mode 100644 index 0000000..44caaa8 --- /dev/null +++ b/tests/test_simulate.py @@ -0,0 +1,188 @@ +"""Tests for the ergonomic simulate API.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from odyssey import Odyssey + + +@pytest.fixture +def mock_auth_and_simulations(): + """Mock auth and simulations clients.""" + with patch.object(Odyssey, "_ensure_simulations_client", new_callable=AsyncMock) as mock_ensure: + yield mock_ensure + + +@pytest.fixture +def client(): + """Create an Odyssey client for testing.""" + return Odyssey(api_key="ody_test_key") + + +class TestSimulateErgonomicAPI: + """Tests for the simple prompt array form of simulate().""" + + async def test_converts_simple_prompt_array_to_script_format(self, client, mock_auth_and_simulations): + """Simple prompt array should convert to proper script format.""" + captured_script = None + + async def capture_submit(script=None, scripts=None, script_url=None, portrait=True): + nonlocal captured_script + captured_script = script + return { + "job_id": "test-job-123", + "status": "pending", + "priority": "normal", + "created_at": "2024-01-01T00:00:00Z", + } + + client._simulations = MagicMock() + client._simulations.submit_job = AsyncMock(side_effect=capture_submit) + + await client.simulate(["First prompt", "Second prompt", "Third prompt"]) + + # Should have 4 entries: start, interact, interact, auto-appended end + assert captured_script is not None + assert len(captured_script) == 4 + + # First entry is start at 0ms + assert captured_script[0]["timestamp_ms"] == 0 + assert captured_script[0]["start"]["prompt"] == "First prompt" + + # Second entry is interact at 3000ms (default interval) + assert captured_script[1]["timestamp_ms"] == 3000 + assert captured_script[1]["interact"]["prompt"] == "Second prompt" + + # Third entry is interact at 6000ms + assert captured_script[2]["timestamp_ms"] == 6000 + assert captured_script[2]["interact"]["prompt"] == "Third prompt" + + # Fourth entry is auto-appended end at last timestamp + 3000ms + assert captured_script[3]["timestamp_ms"] == 9000 + assert "end" in captured_script[3] + + async def test_respects_custom_interval_for_prompt_array(self, client, mock_auth_and_simulations): + """Custom interval should be respected when converting prompts.""" + captured_script = None + + async def capture_submit(script=None, scripts=None, script_url=None, portrait=True): + nonlocal captured_script + captured_script = script + return { + "job_id": "test-job-123", + "status": "pending", + "priority": "normal", + "created_at": "2024-01-01T00:00:00Z", + } + + client._simulations = MagicMock() + client._simulations.submit_job = AsyncMock(side_effect=capture_submit) + + await client.simulate(["A", "B", "C"], interval=5000) + + assert captured_script is not None + assert captured_script[0]["timestamp_ms"] == 0 + assert captured_script[1]["timestamp_ms"] == 5000 + assert captured_script[2]["timestamp_ms"] == 10000 + # Auto-appended end at 10000 + 3000 = 13000 + assert captured_script[3]["timestamp_ms"] == 13000 + + +class TestSimulateAutoAppendEnd: + """Tests for auto-appending end entry.""" + + async def test_auto_appends_end_when_script_does_not_end_with_end(self, client, mock_auth_and_simulations): + """Scripts without end should have end auto-appended.""" + captured_script = None + + async def capture_submit(script=None, scripts=None, script_url=None, portrait=True): + nonlocal captured_script + captured_script = script + return { + "job_id": "test-job-123", + "status": "pending", + "priority": "normal", + "created_at": "2024-01-01T00:00:00Z", + } + + client._simulations = MagicMock() + client._simulations.submit_job = AsyncMock(side_effect=capture_submit) + + await client.simulate( + script=[ + {"timestamp_ms": 0, "start": {"prompt": "Start"}}, + {"timestamp_ms": 5000, "interact": {"prompt": "Middle"}}, + # No end entry + ] + ) + + # Should have auto-appended end + assert captured_script is not None + assert len(captured_script) == 3 + assert captured_script[2]["timestamp_ms"] == 8000 # 5000 + 3000 + assert "end" in captured_script[2] + + async def test_does_not_duplicate_end_when_script_already_has_one(self, client, mock_auth_and_simulations): + """Scripts with end should not get another end appended.""" + captured_script = None + + async def capture_submit(script=None, scripts=None, script_url=None, portrait=True): + nonlocal captured_script + captured_script = script + return { + "job_id": "test-job-123", + "status": "pending", + "priority": "normal", + "created_at": "2024-01-01T00:00:00Z", + } + + client._simulations = MagicMock() + client._simulations.submit_job = AsyncMock(side_effect=capture_submit) + + await client.simulate( + script=[ + {"timestamp_ms": 0, "start": {"prompt": "Start"}}, + {"timestamp_ms": 5000, "interact": {"prompt": "Middle"}}, + {"timestamp_ms": 10000, "end": {}}, # Already has end + ] + ) + + # Should keep the original 3 entries without adding another end + assert captured_script is not None + assert len(captured_script) == 3 + assert captured_script[2]["timestamp_ms"] == 10000 + + +class TestSimulateFullOptions: + """Tests for the full options form of simulate().""" + + async def test_full_options_form_still_works(self, client, mock_auth_and_simulations): + """Full options form should work as before.""" + captured_kwargs = {} + + async def capture_submit(**kwargs): + nonlocal captured_kwargs + captured_kwargs = kwargs + return { + "job_id": "test-job-123", + "status": "pending", + "priority": "normal", + "created_at": "2024-01-01T00:00:00Z", + } + + client._simulations = MagicMock() + client._simulations.submit_job = AsyncMock(side_effect=capture_submit) + + await client.simulate( + script=[ + {"timestamp_ms": 0, "start": {"prompt": "A bird flying"}}, + {"timestamp_ms": 4000, "interact": {"prompt": "The bird lands"}}, + {"timestamp_ms": 8000, "end": {}}, + ], + portrait=False, + ) + + assert captured_kwargs["portrait"] is False + assert captured_kwargs["script"] is not None + assert len(captured_kwargs["script"]) == 3