Skip to content
Draft
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 .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,22 @@ jobs:

- name: Run type checking
run: uv run ty check --output-format github

optional-media:
runs-on: ubuntu-latest
strategy:
matrix:
extras: ["", "--extra media"]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- uses: astral-sh/setup-uv@v6
- name: Install core or media dependencies
run: uv sync --locked --dev ${{ matrix.extras }}
- name: Verify core installation excludes MoviePy
if: matrix.extras == ''
run: uv run --no-sync python -c "import importlib.util; import stirrup; assert importlib.util.find_spec('moviepy') is None"
- name: Test optional media
run: uv run --no-sync pytest tests/test_optional_media.py -q
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@ pip install 'stirrup[docker]' # or: uv add 'stirrup[docker]'
pip install 'stirrup[e2b]' # or: uv add 'stirrup[e2b]'
pip install 'stirrup[mcp]' # or: uv add 'stirrup[mcp]'
pip install 'stirrup[browser]' # or: uv add 'stirrup[browser]'
pip install 'stirrup[media]' # Audio/video validation and transcoding
```

Audio and video content now require `stirrup[media]` (also included in `stirrup[all]`).
Text, images, and screenshot-only browser use do not install or import MoviePy.

## Quick Start

```python
Expand Down
1 change: 1 addition & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pip install 'stirrup[all]' # or: uv add 'stirrup[all]'
| `docker` | Docker-based code execution |
| `e2b` | E2B cloud sandboxes |
| `mcp` | MCP server support |
| `media` | Audio/video validation and transcoding |
| `all` | All optional components |

## Your First Agent
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pip install 'stirrup[docker]' # or: uv add 'stirrup[docker]'
pip install 'stirrup[e2b]' # or: uv add 'stirrup[e2b]'
pip install 'stirrup[mcp]' # or: uv add 'stirrup[mcp]'
pip install 'stirrup[browser]' # or: uv add 'stirrup[browser]'
pip install 'stirrup[media]' # Audio/video validation and transcoding
```

## Quick Start
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ dependencies = [
"filetype>=1.0.0",
"httpx>=0.25.0",
"json-schema-to-pydantic>=0.2.0",
"moviepy>=2.0.0",
"openai>=1.0.0",
"pillow>=10.4.0",
"pydantic>=2.0.0",
Expand All @@ -38,14 +37,15 @@ dependencies = [
]

[project.optional-dependencies]
media = ["moviepy>=2.0.0"]
litellm = ["litellm>=1.79.3"]
e2b = ["e2b-code-interpreter>=2.3.0"]
e2b-throttle = ["stirrup[e2b]", "aiolimiter>=1.1.0"]
docker = ["docker>=7.0.0", "python-dotenv>=1.0.0"]
mcp = ["mcp>=1.9.0"]
browser = ["browser-use>=0.11.3"]
slack = ["slack-bolt>=1.20.0"]
all = ["stirrup[litellm,e2b,e2b-throttle,docker,mcp,browser,slack]"]
all = ["stirrup[litellm,e2b,e2b-throttle,docker,mcp,browser,slack,media]"]

[project.urls]
Homepage = "https://github.com/ArtificialAnalysis/Stirrup"
Expand Down
31 changes: 29 additions & 2 deletions src/stirrup/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
from uuid import uuid4

import filetype
from moviepy import AudioFileClip, VideoFileClip
from moviepy.video.fx import Resize
from PIL import Image
from pydantic import (
BaseModel,
Expand Down Expand Up @@ -187,6 +185,13 @@ class VideoContentBlock(BinaryContentBlock, frozen=True):

def _probe(self) -> None:
"""Verify video data is valid by attempting to open it as a VideoFileClip."""
try:
from moviepy import VideoFileClip
except ModuleNotFoundError as exc:
if exc.name != "moviepy":
raise
raise ImportError("Audio/video support requires pip install 'stirrup[media]'.") from exc

with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning, module="moviepy.*")
with NamedTemporaryFile(suffix=".bin") as f:
Expand All @@ -197,6 +202,14 @@ def _probe(self) -> None:

def to_base64_url(self, max_pixels: int | None = RESOLUTION_480P, fps: int | None = None) -> str:
"""Transcode to MP4 and return base64 data URL."""
try:
from moviepy import VideoFileClip
from moviepy.video.fx import Resize
except ModuleNotFoundError as exc:
if exc.name != "moviepy":
raise
raise ImportError("Audio/video support requires pip install 'stirrup[media]'.") from exc

with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning, module="moviepy.*")
with NamedTemporaryFile(suffix=".mp4") as fin, NamedTemporaryFile(suffix=".mp4") as fout:
Expand Down Expand Up @@ -241,6 +254,13 @@ class AudioContentBlock(BinaryContentBlock, frozen=True):

def _probe(self) -> None:
"""Verify audio data is valid by attempting to open it as an AudioFileClip."""
try:
from moviepy import AudioFileClip
except ModuleNotFoundError as exc:
if exc.name != "moviepy":
raise
raise ImportError("Audio/video support requires pip install 'stirrup[media]'.") from exc

with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning, module="moviepy.*")
with NamedTemporaryFile(suffix=".bin") as fin:
Expand All @@ -251,6 +271,13 @@ def _probe(self) -> None:

def to_base64_url(self, bitrate: str = "192k") -> str:
"""Transcode to MP3 and return base64 data URL."""
try:
from moviepy import AudioFileClip
except ModuleNotFoundError as exc:
if exc.name != "moviepy":
raise
raise ImportError("Audio/video support requires pip install 'stirrup[media]'.") from exc

with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning, module="moviepy.*")
with NamedTemporaryFile(suffix=".bin") as fin, NamedTemporaryFile(suffix=".mp3") as fout:
Expand Down
69 changes: 69 additions & 0 deletions tests/test_optional_media.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Check screenshot-only installs and optional audio/video support."""

import subprocess
import sys
from pathlib import Path

import pytest


def test_images_work_without_moviepy() -> None:
subprocess.run(
[
sys.executable,
"-c",
"""
import importlib.abc
import io
import sys

class NoMoviePy(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname == "moviepy" or fullname.startswith("moviepy."):
raise ModuleNotFoundError("MoviePy is absent", name="moviepy")

sys.meta_path.insert(0, NoMoviePy())
from PIL import Image
from stirrup import ImageContentBlock, AudioContentBlock, VideoContentBlock

buffer = io.BytesIO()
Image.new("RGB", (2, 2)).save(buffer, format="PNG")
assert ImageContentBlock(data=buffer.getvalue()).to_base64_url().startswith("data:image/png;base64,")
assert "moviepy" not in sys.modules

for cls in (AudioContentBlock, VideoContentBlock):
block = cls.model_construct(data=b"")
for operation in (block._probe, block.to_base64_url):
try:
operation()
except ImportError as exc:
assert "stirrup[media]" in str(exc)
else:
raise AssertionError("Expected installation guidance")
""",
],
check=True,
)


def test_media_extra_transcodes_audio_and_video(tmp_path: Path) -> None:
pytest.importorskip("moviepy")
import wave
from io import BytesIO

from moviepy import ColorClip

from stirrup import AudioContentBlock, VideoContentBlock

audio = BytesIO()
with wave.open(audio, "wb") as stream:
stream.setnchannels(1)
stream.setsampwidth(2)
stream.setframerate(8000)
stream.writeframes(b"\0\0" * 4000)
assert AudioContentBlock(data=audio.getvalue()).to_base64_url().startswith("data:audio/mpeg;base64,")

video = tmp_path / "sample.mp4"
with ColorClip(size=(16, 16), color=(0, 0, 0), duration=0.5) as clip:
clip.write_videofile(str(video), fps=10, codec="libx264", logger=None)
assert VideoContentBlock(data=video.read_bytes()).to_base64_url(fps=10).startswith("data:video/mp4;base64,")
Loading
Loading