Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,36 @@ async with client.connect(session_options=session_options) as session:

**Daily tokens.** Mint meeting tokens through your own Daily app (Daily REST API or a server you control); the SDK never does this for you. A Daily meeting token is bound to the room it was minted for, so the `token` you pass must be minted for the same `room_url` — passing a token minted for a different room will fail at join time with a 403. For public rooms (no token required) you can omit `token` entirely.

## Output Video Dimensions

Optional. Request a specific output resolution via `video_width` / `video_height` on `SessionOptions`. Both must be set together (or both left unset); unsupported model/resolution combinations are rejected by the API. When unset, output defaults to landscape for the avatar model.

| avatar model | orientation | width | height |
|--------------|-------------|-------|--------|
| Cara-3 | landscape | 720 | 480 |
| Cara-4 | landscape | 1152 | 768 |
| Cara-4 | portrait | 768 | 1152 |

Landscape (Cara-4 default):

```python
from anam import SessionOptions

session_options = SessionOptions(video_width=1152, video_height=768)
async with client.connect(session_options=session_options) as session:
...
```

Portrait (Cara-4 only — the avatar must be on a portrait-capable Cara-4 model):

```python
from anam import SessionOptions

session_options = SessionOptions(video_width=768, video_height=1152)
async with client.connect(session_options=session_options) as session:
...
```

## API Reference

### AnamClient
Expand Down
17 changes: 17 additions & 0 deletions src/anam/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,15 @@ class SessionOptions:
Args:
enable_session_replay: If True (default), session is recorded. Set False to disable.
video_quality: Video quality profile to pin the video quality. Supported values are "high" (default) and "auto".
video_width: Requested video output width. Must be provided with video_height.
video_height: Requested video output height. Must be provided with video_width.
egress: Optional direct egress to a third-party transport (e.g. Daily). See :class:`EgressOptions`.
"""

enable_session_replay: bool = True
video_quality: Literal["high", "auto"] = "high"
video_width: int | None = None
video_height: int | None = None
egress: EgressOptions | None = None

def __post_init__(self) -> None:
Expand All @@ -218,12 +222,25 @@ def __post_init__(self) -> None:
)
if self.video_quality not in {"high", "auto"}:
raise ValueError('video_quality must be either "high" or "auto"')
if (self.video_width is None) != (self.video_height is None):
raise ValueError("video_width and video_height must be provided together")
for name, value in (
("video_width", self.video_width),
("video_height", self.video_height),
):
if value is not None and (
isinstance(value, bool) or not isinstance(value, int) or value <= 0
):
raise ValueError(f"{name} must be a positive integer")

def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {
"sessionReplay": self._session_replay.to_dict(),
"videoQuality": self.video_quality,
}
if self.video_width is not None and self.video_height is not None:
result["videoWidth"] = self.video_width
result["videoHeight"] = self.video_height
if self.egress is not None:
result["egress"] = self.egress.to_dict()
return result
Expand Down
47 changes: 47 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import math
from typing import Any
from unittest.mock import AsyncMock, MagicMock

import pytest
Expand Down Expand Up @@ -271,10 +272,56 @@ def test_to_dict_with_video_quality_auto(self) -> None:
"videoQuality": "auto",
}

def test_to_dict_with_video_dimensions(self) -> None:
options = SessionOptions(video_width=1152, video_height=768)
result = options.to_dict()

assert result == {
"sessionReplay": {"enableSessionReplay": True},
"videoQuality": "high",
"videoWidth": 1152,
"videoHeight": 768,
}

def test_invalid_video_quality_raises_value_error(self) -> None:
with pytest.raises(ValueError, match='video_quality must be either "high" or "auto"'):
SessionOptions(video_quality="medium") # type: ignore[arg-type]

@pytest.mark.parametrize(
"kwargs",
[
{"video_width": 1152},
{"video_height": 768},
],
)
def test_video_dimensions_must_be_provided_together(self, kwargs: dict[str, Any]) -> None:
with pytest.raises(
ValueError,
match="video_width and video_height must be provided together",
):
SessionOptions(**kwargs)

@pytest.mark.parametrize(
("field", "value"),
[
("video_width", 0),
("video_width", -1),
("video_width", 1.5),
("video_width", True),
("video_height", 0),
("video_height", -1),
("video_height", 1.5),
("video_height", True),
],
)
def test_video_dimensions_must_be_positive_integers(
self, field: str, value: int | float | bool
) -> None:
kwargs: dict[str, Any] = {"video_width": 1152, "video_height": 768, field: value}

with pytest.raises(ValueError, match=f"{field} must be a positive integer"):
SessionOptions(**kwargs) # type: ignore[arg-type]


class TestDirectorNoteCue:
"""Tests for Session.send_director_note_cue."""
Expand Down
Loading