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
19 changes: 19 additions & 0 deletions client/joinly_client/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import base64
import contextlib
import json
import logging
Expand All @@ -19,6 +20,7 @@
Transcript,
TranscriptSegment,
Usage,
VideoSnapshot,
)
from joinly_client.utils import is_async_context, name_in_transcript

Expand Down Expand Up @@ -410,6 +412,23 @@ async def send_chat_message(self, message: str) -> None:
arguments={"message": message},
)

async def get_video_snapshot(self) -> VideoSnapshot:
"""Get a snapshot of the current video feed.

Returns:
VideoSnapshot: The snapshot with raw image data and media type.
"""
if not self.joined:
msg = "Not joined to a meeting"
raise RuntimeError(msg)

result = await self.client.call_tool("get_video_snapshot")
content = result.content[0]
return VideoSnapshot(
data=base64.b64decode(content.data), # type: ignore[union-attr]
media_type=content.mimeType, # type: ignore[union-attr]
)

async def share_screen(self, url: str) -> None:
"""Start sharing screen in the meeting.

Expand Down
2 changes: 2 additions & 0 deletions client/joinly_client/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Transcript,
TranscriptSegment,
Usage,
VideoSnapshot,
)
from mcp.types import CallToolResult

Expand All @@ -26,6 +27,7 @@
"Transcript",
"TranscriptSegment",
"Usage",
"VideoSnapshot",
]

type ToolExecutor = Callable[[str, dict[str, Any]], Awaitable[Any]]
Expand Down
13 changes: 13 additions & 0 deletions common/joinly_common/types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from collections.abc import Iterable
from decimal import ROUND_HALF_UP, Decimal
from enum import Enum
from typing import Literal

from pydantic import (
BaseModel,
Expand Down Expand Up @@ -167,6 +168,18 @@ def compact(self, max_gap: float = 0.5) -> "Transcript":
return Transcript(segments=compacted)


class VideoSnapshot(BaseModel):
"""A snapshot of the meeting video feed.

Attributes:
data (bytes): The raw image data.
media_type (Literal["image/jpeg", "image/png"]): The media type of the image.
"""

data: bytes
media_type: Literal["image/jpeg", "image/png"] = "image/jpeg"


class MeetingChatMessage(BaseModel):
"""A class to represent a chat message in a meeting.

Expand Down
16 changes: 2 additions & 14 deletions joinly/types.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from dataclasses import dataclass
from typing import Literal

from joinly_common.types import (
MeetingChatHistory,
Expand All @@ -11,6 +10,7 @@
Transcript,
TranscriptSegment,
Usage,
VideoSnapshot,
)

__all__ = [
Expand All @@ -23,6 +23,7 @@
"Transcript",
"TranscriptSegment",
"Usage",
"VideoSnapshot",
]


Expand Down Expand Up @@ -92,16 +93,3 @@ class SpeechWindow:
time_ns: int
is_speech: bool
speaker: str | None = None


@dataclass(frozen=True, slots=True)
class VideoSnapshot:
"""A class to represent a snapshot of video data.

Attributes:
data (bytes): The raw video data.
media_type (Literal["image/png"]): The media type of the video snapshot.
"""

data: bytes
media_type: Literal["image/png", "image/jpeg"] = "image/png"
15 changes: 4 additions & 11 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
"""

import asyncio
import base64
import io
import os
from collections.abc import AsyncIterator
Expand Down Expand Up @@ -75,12 +74,6 @@ def _red_ratio(image_data: bytes) -> float:
return red_count / len(pixels)


async def _snapshot_image(bot: JoinlyClient) -> bytes:
"""Take a video snapshot and return the raw image bytes."""
result = await bot.client.call_tool("get_video_snapshot")
return base64.b64decode(result.content[0].data) # type: ignore[union-attr]


async def _transcript_text(bot: JoinlyClient) -> str:
"""Get the full transcript text from a bot, lowercased."""
transcript = await bot.get_transcript()
Expand Down Expand Up @@ -198,19 +191,19 @@ async def test_screen_share(
# share red page and verify >30% near-pure red pixels
await bot_a.share_screen(_RED_PAGE)
await asyncio.sleep(8)
ratio = _red_ratio(await _snapshot_image(bot_b))
ratio = _red_ratio((await bot_b.get_video_snapshot()).data)
assert ratio > 0.3, f"Only {ratio:.0%} red pixels during share" # noqa: PLR2004

# stop and verify red is gone
await bot_a.stop_sharing()
await asyncio.sleep(5)
ratio = _red_ratio(await _snapshot_image(bot_b))
ratio = _red_ratio((await bot_b.get_video_snapshot()).data)
assert ratio < 0.05, f"Still {ratio:.0%} red after stop" # noqa: PLR2004

# re-share to verify share works again after stop
await bot_a.share_screen(_RED_PAGE)
await asyncio.sleep(5)
ratio = _red_ratio(await _snapshot_image(bot_b))
ratio = _red_ratio((await bot_b.get_video_snapshot()).data)
assert ratio > 0.3, f"Only {ratio:.0%} red on re-share" # noqa: PLR2004
await bot_a.stop_sharing()
await asyncio.sleep(2)
Expand Down Expand Up @@ -279,7 +272,7 @@ async def test_video_snapshot_is_valid_image(
"""Video snapshot should be a decodable JPEG image of reasonable size."""
bot_a, _bot_b = bots

img_bytes = await _snapshot_image(bot_a)
img_bytes = (await bot_a.get_video_snapshot()).data
img = Image.open(io.BytesIO(img_bytes))

assert img.format == "JPEG", f"Expected JPEG, got {img.format}"
Expand Down