runapi-gemini-omni 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runapi_gemini_omni-0.1.0/.gitignore +29 -0
- runapi_gemini_omni-0.1.0/PKG-INFO +82 -0
- runapi_gemini_omni-0.1.0/README.md +69 -0
- runapi_gemini_omni-0.1.0/pyproject.toml +30 -0
- runapi_gemini_omni-0.1.0/src/runapi/gemini_omni/__init__.py +24 -0
- runapi_gemini_omni-0.1.0/src/runapi/gemini_omni/client.py +29 -0
- runapi_gemini_omni-0.1.0/src/runapi/gemini_omni/py.typed +0 -0
- runapi_gemini_omni-0.1.0/src/runapi/gemini_omni/resources/__init__.py +5 -0
- runapi_gemini_omni-0.1.0/src/runapi/gemini_omni/resources/create_audio.py +60 -0
- runapi_gemini_omni-0.1.0/src/runapi/gemini_omni/resources/create_character.py +65 -0
- runapi_gemini_omni-0.1.0/src/runapi/gemini_omni/resources/text_to_video.py +177 -0
- runapi_gemini_omni-0.1.0/src/runapi/gemini_omni/types.py +62 -0
- runapi_gemini_omni-0.1.0/tests/test_client.py +164 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Build artifacts
|
|
2
|
+
dist/
|
|
3
|
+
build/
|
|
4
|
+
*.egg-info/
|
|
5
|
+
*.egg
|
|
6
|
+
|
|
7
|
+
# Bytecode
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
|
|
15
|
+
# uv
|
|
16
|
+
uv.lock
|
|
17
|
+
|
|
18
|
+
# Test / type caches
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.mypy_cache/
|
|
21
|
+
.ruff_cache/
|
|
22
|
+
.coverage
|
|
23
|
+
htmlcov/
|
|
24
|
+
|
|
25
|
+
# IDE / OS
|
|
26
|
+
.idea/
|
|
27
|
+
.vscode/
|
|
28
|
+
*.swp
|
|
29
|
+
.DS_Store
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runapi-gemini-omni
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Gemini Omni voice, character, and text-to-video client for RunAPI
|
|
5
|
+
Project-URL: Homepage, https://runapi.ai/models/gemini-omni
|
|
6
|
+
Project-URL: Documentation, https://runapi.ai/docs#sdk-gemini-omni
|
|
7
|
+
Author-email: RunAPI <contact@runapi.ai>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: ai,gemini,gemini-omni,runapi,sdk,text-to-video
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: runapi-core
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Gemini Omni Python SDK for RunAPI
|
|
15
|
+
|
|
16
|
+
The Gemini Omni Python SDK is the language-specific package for Gemini Omni on
|
|
17
|
+
RunAPI. Use it to create reusable voices and characters and to generate video
|
|
18
|
+
from text, with JSON request bodies, task status lookup, and consistent RunAPI
|
|
19
|
+
errors in Python.
|
|
20
|
+
|
|
21
|
+
For model details, use https://runapi.ai/models/gemini-omni; for API reference,
|
|
22
|
+
use https://runapi.ai/docs#gemini-omni; for SDK docs, use
|
|
23
|
+
https://runapi.ai/docs#sdk-gemini-omni.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install runapi-gemini-omni
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from runapi.gemini_omni import GeminiOmniClient
|
|
35
|
+
|
|
36
|
+
client = GeminiOmniClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
|
|
37
|
+
|
|
38
|
+
# Reusable voice (returns immediately)
|
|
39
|
+
voice = client.create_audio.run(audio_id="kore", name="Narrator")
|
|
40
|
+
|
|
41
|
+
# Reusable character (returns immediately)
|
|
42
|
+
character = client.create_character.run(
|
|
43
|
+
descriptions="A friendly robot guide",
|
|
44
|
+
reference_image_url="https://example.com/robot.png",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Text-to-video (create + poll until complete)
|
|
48
|
+
result = client.text_to_video.run(
|
|
49
|
+
prompt="A fox trotting across fresh snow at dawn",
|
|
50
|
+
duration_seconds=8,
|
|
51
|
+
aspect_ratio="16:9",
|
|
52
|
+
)
|
|
53
|
+
print(result.videos[0].url)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`create_audio` and `create_character` are synchronous: `run` submits and returns
|
|
57
|
+
the result directly. `text_to_video` is asynchronous: use `create` to submit and
|
|
58
|
+
return quickly, `get` to fetch the latest task state, and `run` to create and
|
|
59
|
+
poll until completion.
|
|
60
|
+
|
|
61
|
+
RunAPI-generated file URLs are temporary. Download and store generated files in
|
|
62
|
+
your own durable storage within 7 days; do not treat returned URLs as long-term
|
|
63
|
+
assets.
|
|
64
|
+
|
|
65
|
+
## Language notes
|
|
66
|
+
|
|
67
|
+
Pass parameters as keyword arguments and catch the `runapi.gemini_omni` error
|
|
68
|
+
classes. The available resources are `create_audio`, `create_character`, and
|
|
69
|
+
`text_to_video`. Keep `RUNAPI_API_KEY` in the environment or your secret
|
|
70
|
+
manager; never commit API keys or callback secrets.
|
|
71
|
+
|
|
72
|
+
## Links
|
|
73
|
+
|
|
74
|
+
- Model page: https://runapi.ai/models/gemini-omni
|
|
75
|
+
- SDK docs: https://runapi.ai/docs#sdk-gemini-omni
|
|
76
|
+
- Product docs: https://runapi.ai/docs#gemini-omni
|
|
77
|
+
- Pricing and rate limits: https://runapi.ai/models/gemini-omni
|
|
78
|
+
- Full catalog: https://runapi.ai/models
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
Licensed under the Apache License, Version 2.0.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Gemini Omni Python SDK for RunAPI
|
|
2
|
+
|
|
3
|
+
The Gemini Omni Python SDK is the language-specific package for Gemini Omni on
|
|
4
|
+
RunAPI. Use it to create reusable voices and characters and to generate video
|
|
5
|
+
from text, with JSON request bodies, task status lookup, and consistent RunAPI
|
|
6
|
+
errors in Python.
|
|
7
|
+
|
|
8
|
+
For model details, use https://runapi.ai/models/gemini-omni; for API reference,
|
|
9
|
+
use https://runapi.ai/docs#gemini-omni; for SDK docs, use
|
|
10
|
+
https://runapi.ai/docs#sdk-gemini-omni.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install runapi-gemini-omni
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from runapi.gemini_omni import GeminiOmniClient
|
|
22
|
+
|
|
23
|
+
client = GeminiOmniClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
|
|
24
|
+
|
|
25
|
+
# Reusable voice (returns immediately)
|
|
26
|
+
voice = client.create_audio.run(audio_id="kore", name="Narrator")
|
|
27
|
+
|
|
28
|
+
# Reusable character (returns immediately)
|
|
29
|
+
character = client.create_character.run(
|
|
30
|
+
descriptions="A friendly robot guide",
|
|
31
|
+
reference_image_url="https://example.com/robot.png",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Text-to-video (create + poll until complete)
|
|
35
|
+
result = client.text_to_video.run(
|
|
36
|
+
prompt="A fox trotting across fresh snow at dawn",
|
|
37
|
+
duration_seconds=8,
|
|
38
|
+
aspect_ratio="16:9",
|
|
39
|
+
)
|
|
40
|
+
print(result.videos[0].url)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`create_audio` and `create_character` are synchronous: `run` submits and returns
|
|
44
|
+
the result directly. `text_to_video` is asynchronous: use `create` to submit and
|
|
45
|
+
return quickly, `get` to fetch the latest task state, and `run` to create and
|
|
46
|
+
poll until completion.
|
|
47
|
+
|
|
48
|
+
RunAPI-generated file URLs are temporary. Download and store generated files in
|
|
49
|
+
your own durable storage within 7 days; do not treat returned URLs as long-term
|
|
50
|
+
assets.
|
|
51
|
+
|
|
52
|
+
## Language notes
|
|
53
|
+
|
|
54
|
+
Pass parameters as keyword arguments and catch the `runapi.gemini_omni` error
|
|
55
|
+
classes. The available resources are `create_audio`, `create_character`, and
|
|
56
|
+
`text_to_video`. Keep `RUNAPI_API_KEY` in the environment or your secret
|
|
57
|
+
manager; never commit API keys or callback secrets.
|
|
58
|
+
|
|
59
|
+
## Links
|
|
60
|
+
|
|
61
|
+
- Model page: https://runapi.ai/models/gemini-omni
|
|
62
|
+
- SDK docs: https://runapi.ai/docs#sdk-gemini-omni
|
|
63
|
+
- Product docs: https://runapi.ai/docs#gemini-omni
|
|
64
|
+
- Pricing and rate limits: https://runapi.ai/models/gemini-omni
|
|
65
|
+
- Full catalog: https://runapi.ai/models
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
Licensed under the Apache License, Version 2.0.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runapi-gemini-omni"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Gemini Omni voice, character, and text-to-video client for RunAPI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "RunAPI", email = "contact@runapi.ai" }]
|
|
13
|
+
keywords = ["runapi", "gemini", "gemini-omni", "text-to-video", "ai", "sdk"]
|
|
14
|
+
dependencies = ["runapi-core"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://runapi.ai/models/gemini-omni"
|
|
18
|
+
Documentation = "https://runapi.ai/docs#sdk-gemini-omni"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/runapi"]
|
|
22
|
+
|
|
23
|
+
[tool.uv]
|
|
24
|
+
package = true
|
|
25
|
+
|
|
26
|
+
[tool.uv.sources]
|
|
27
|
+
runapi-core = { path = "../runapi-core", editable = true }
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = ["pytest>=8"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Gemini Omni client for RunAPI."""
|
|
2
|
+
|
|
3
|
+
from runapi.core import (
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
InsufficientCreditsError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
TaskFailedError,
|
|
9
|
+
TaskTimeoutError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .client import GeminiOmniClient
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"GeminiOmniClient",
|
|
17
|
+
"AuthenticationError",
|
|
18
|
+
"RateLimitError",
|
|
19
|
+
"InsufficientCreditsError",
|
|
20
|
+
"NotFoundError",
|
|
21
|
+
"ValidationError",
|
|
22
|
+
"TaskFailedError",
|
|
23
|
+
"TaskTimeoutError",
|
|
24
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Gemini Omni client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from runapi.core import ClientOptions, HttpClient, resolve_api_key
|
|
8
|
+
|
|
9
|
+
from .resources.create_audio import CreateAudio
|
|
10
|
+
from .resources.create_character import CreateCharacter
|
|
11
|
+
from .resources.text_to_video import TextToVideo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GeminiOmniClient:
|
|
15
|
+
"""Gemini Omni client: reusable voices, characters, and text-to-video.
|
|
16
|
+
|
|
17
|
+
Example::
|
|
18
|
+
|
|
19
|
+
client = GeminiOmniClient(api_key="sk-...")
|
|
20
|
+
result = client.text_to_video.run(prompt="A fox in snow", duration_seconds=8)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
|
|
24
|
+
resolved_api_key = resolve_api_key(api_key)
|
|
25
|
+
client_options = ClientOptions(api_key=resolved_api_key, **options)
|
|
26
|
+
http = client_options.http_client or HttpClient(client_options)
|
|
27
|
+
self.create_audio = CreateAudio(http)
|
|
28
|
+
self.create_character = CreateCharacter(http)
|
|
29
|
+
self.text_to_video = TextToVideo(http)
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Gemini Omni create-audio resource (synchronous)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from runapi.core import Resource, ValidationError
|
|
8
|
+
|
|
9
|
+
from ..types import CreateAudioResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CreateAudio(Resource):
|
|
13
|
+
"""Create a reusable voice. Synchronous: ``run()`` returns the result directly."""
|
|
14
|
+
|
|
15
|
+
ENDPOINT = "/api/v1/gemini_omni/create_audio"
|
|
16
|
+
|
|
17
|
+
RESPONSE_CLASS = CreateAudioResponse
|
|
18
|
+
|
|
19
|
+
NAME_MAX_LENGTH = 210
|
|
20
|
+
VOICE_DESCRIPTION_MAX_LENGTH = 20_000
|
|
21
|
+
EXAMPLE_DIALOGUE_MAX_LENGTH = 120
|
|
22
|
+
|
|
23
|
+
def run(self, **params: Any) -> Any:
|
|
24
|
+
"""Create a reusable voice (synchronous).
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
**params: Reusable voice parameters (audio_id, name, ...).
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The result.
|
|
31
|
+
"""
|
|
32
|
+
compacted = self._compact_params(params)
|
|
33
|
+
self._validate_params(compacted)
|
|
34
|
+
return self._request("post", self.ENDPOINT, body=compacted)
|
|
35
|
+
|
|
36
|
+
def _validate_params(self, params: Dict[str, Any]) -> None:
|
|
37
|
+
self._validate_required(params, "audio_id")
|
|
38
|
+
self._validate_required(params, "name")
|
|
39
|
+
self._validate_length(params, "name", self.NAME_MAX_LENGTH)
|
|
40
|
+
self._validate_length(params, "voice_description", self.VOICE_DESCRIPTION_MAX_LENGTH)
|
|
41
|
+
self._validate_length(params, "example_dialogue", self.EXAMPLE_DIALOGUE_MAX_LENGTH)
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def _validate_required(params: Dict[str, Any], key: str) -> None:
|
|
45
|
+
value = params.get(key)
|
|
46
|
+
if isinstance(value, list):
|
|
47
|
+
present = len(value) > 0
|
|
48
|
+
elif isinstance(value, str):
|
|
49
|
+
present = value != ""
|
|
50
|
+
else:
|
|
51
|
+
present = value is not None
|
|
52
|
+
if not present:
|
|
53
|
+
raise ValidationError(f"{key} is required")
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _validate_length(params: Dict[str, Any], key: str, max_length: int) -> None:
|
|
57
|
+
value = params.get(key)
|
|
58
|
+
if value is None or len(str(value)) <= max_length:
|
|
59
|
+
return
|
|
60
|
+
raise ValidationError(f"{key} must be at most {max_length} characters")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Gemini Omni create-character resource (synchronous)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from runapi.core import Resource, ValidationError
|
|
8
|
+
|
|
9
|
+
from ..types import CreateCharacterResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CreateCharacter(Resource):
|
|
13
|
+
"""Create a reusable character. Synchronous: ``run()`` returns the result directly."""
|
|
14
|
+
|
|
15
|
+
ENDPOINT = "/api/v1/gemini_omni/create_character"
|
|
16
|
+
|
|
17
|
+
RESPONSE_CLASS = CreateCharacterResponse
|
|
18
|
+
|
|
19
|
+
DESCRIPTIONS_MAX_LENGTH = 20_000
|
|
20
|
+
CHARACTER_NAME_MAX_LENGTH = 210
|
|
21
|
+
|
|
22
|
+
def run(self, **params: Any) -> Any:
|
|
23
|
+
"""Create a reusable character (synchronous).
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
**params: Reusable character parameters (descriptions, reference_image_url, ...).
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The result.
|
|
30
|
+
"""
|
|
31
|
+
compacted = self._compact_params(params)
|
|
32
|
+
self._validate_params(compacted)
|
|
33
|
+
return self._request("post", self.ENDPOINT, body=compacted)
|
|
34
|
+
|
|
35
|
+
def _validate_params(self, params: Dict[str, Any]) -> None:
|
|
36
|
+
self._validate_required(params, "descriptions")
|
|
37
|
+
self._validate_required(params, "reference_image_url")
|
|
38
|
+
if params.get("audio_ids") is not None:
|
|
39
|
+
self._validate_array(params, "audio_ids")
|
|
40
|
+
self._validate_length(params, "descriptions", self.DESCRIPTIONS_MAX_LENGTH)
|
|
41
|
+
self._validate_length(params, "character_name", self.CHARACTER_NAME_MAX_LENGTH)
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def _validate_required(params: Dict[str, Any], key: str) -> None:
|
|
45
|
+
value = params.get(key)
|
|
46
|
+
if isinstance(value, list):
|
|
47
|
+
present = len(value) > 0
|
|
48
|
+
elif isinstance(value, str):
|
|
49
|
+
present = value != ""
|
|
50
|
+
else:
|
|
51
|
+
present = value is not None
|
|
52
|
+
if not present:
|
|
53
|
+
raise ValidationError(f"{key} is required")
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _validate_array(params: Dict[str, Any], key: str) -> None:
|
|
57
|
+
if not isinstance(params.get(key), list):
|
|
58
|
+
raise ValidationError(f"{key} must be an array")
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _validate_length(params: Dict[str, Any], key: str, max_length: int) -> None:
|
|
62
|
+
value = params.get(key)
|
|
63
|
+
if value is None or len(str(value)) <= max_length:
|
|
64
|
+
return
|
|
65
|
+
raise ValidationError(f"{key} must be at most {max_length} characters")
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Gemini Omni text-to-video resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from runapi.core import Resource, ValidationError
|
|
8
|
+
|
|
9
|
+
from ..types import (
|
|
10
|
+
ASPECT_RATIOS,
|
|
11
|
+
DURATIONS,
|
|
12
|
+
OUTPUT_RESOLUTIONS,
|
|
13
|
+
SEED_MAX,
|
|
14
|
+
SEED_MIN,
|
|
15
|
+
CompletedTextToVideoResponse,
|
|
16
|
+
TextToVideoResponse,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TextToVideo(Resource):
|
|
21
|
+
"""Generate video from a prompt, reference media, and characters."""
|
|
22
|
+
|
|
23
|
+
ENDPOINT = "/api/v1/gemini_omni/text_to_video"
|
|
24
|
+
|
|
25
|
+
RESPONSE_CLASS = TextToVideoResponse
|
|
26
|
+
COMPLETED_RESPONSE_CLASS = CompletedTextToVideoResponse
|
|
27
|
+
|
|
28
|
+
PROMPT_MAX_LENGTH = 20_000
|
|
29
|
+
REFERENCE_IMAGE_URLS_MAX = 7
|
|
30
|
+
AUDIO_IDS_MAX = 3
|
|
31
|
+
VIDEO_LIST_MAX = 1
|
|
32
|
+
CHARACTER_IDS_MAX = 3
|
|
33
|
+
REFERENCE_UNITS_MAX = 7
|
|
34
|
+
VIDEO_REFERENCE_UNITS = 2
|
|
35
|
+
MAX_TRIM_SECONDS = 10
|
|
36
|
+
|
|
37
|
+
def run(self, **params: Any) -> Any:
|
|
38
|
+
"""Create a text-to-video task and poll until it completes.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
**params: Text-to-video parameters (model, prompt, ...).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The completed text-to-video response.
|
|
45
|
+
"""
|
|
46
|
+
task = self.create(**params)
|
|
47
|
+
return self._poll_until_complete(lambda: self.get(task.id))
|
|
48
|
+
|
|
49
|
+
def create(self, **params: Any) -> Any:
|
|
50
|
+
"""Create a text-to-video task and return immediately with an id.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
**params: Text-to-video parameters (model, prompt, ...).
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The task creation result with an id.
|
|
57
|
+
"""
|
|
58
|
+
compacted = self._compact_params(params)
|
|
59
|
+
self._validate_params(compacted)
|
|
60
|
+
return self._request("post", self.ENDPOINT, body=compacted)
|
|
61
|
+
|
|
62
|
+
def get(self, id: str) -> Any:
|
|
63
|
+
"""Fetch the current status of a text-to-video task.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
id: Task id.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The current text-to-video status.
|
|
70
|
+
"""
|
|
71
|
+
return self._request("get", f"{self.ENDPOINT}/{id}")
|
|
72
|
+
|
|
73
|
+
def _validate_params(self, params: Dict[str, Any]) -> None:
|
|
74
|
+
self._validate_required(params, "prompt")
|
|
75
|
+
self._validate_required(params, "duration_seconds")
|
|
76
|
+
self._validate_length(params, "prompt", self.PROMPT_MAX_LENGTH)
|
|
77
|
+
self._validate_optional(params, "duration_seconds", DURATIONS)
|
|
78
|
+
self._validate_optional(params, "aspect_ratio", ASPECT_RATIOS)
|
|
79
|
+
self._validate_optional(params, "output_resolution", OUTPUT_RESOLUTIONS)
|
|
80
|
+
if params.get("reference_image_urls") is not None:
|
|
81
|
+
self._validate_array(params, "reference_image_urls", self.REFERENCE_IMAGE_URLS_MAX)
|
|
82
|
+
if params.get("audio_ids") is not None:
|
|
83
|
+
self._validate_array(params, "audio_ids", self.AUDIO_IDS_MAX)
|
|
84
|
+
if params.get("video_list") is not None:
|
|
85
|
+
self._validate_array(params, "video_list", self.VIDEO_LIST_MAX)
|
|
86
|
+
if params.get("character_ids") is not None:
|
|
87
|
+
self._validate_array(params, "character_ids", self.CHARACTER_IDS_MAX)
|
|
88
|
+
if params.get("video_list") is not None:
|
|
89
|
+
self._validate_video_list(params.get("video_list"))
|
|
90
|
+
self._validate_reference_units(params)
|
|
91
|
+
self._validate_seed(params)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _validate_required(params: Dict[str, Any], key: str) -> None:
|
|
95
|
+
value = params.get(key)
|
|
96
|
+
if isinstance(value, list):
|
|
97
|
+
present = len(value) > 0
|
|
98
|
+
elif isinstance(value, str):
|
|
99
|
+
present = value != ""
|
|
100
|
+
else:
|
|
101
|
+
present = value is not None
|
|
102
|
+
if not present:
|
|
103
|
+
raise ValidationError(f"{key} is required")
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _validate_length(params: Dict[str, Any], key: str, max_length: int) -> None:
|
|
107
|
+
value = params.get(key)
|
|
108
|
+
if value is None or len(str(value)) <= max_length:
|
|
109
|
+
return
|
|
110
|
+
raise ValidationError(f"{key} must be at most {max_length} characters")
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _validate_array(params: Dict[str, Any], key: str, max_length: int) -> None:
|
|
114
|
+
value = params.get(key)
|
|
115
|
+
if not isinstance(value, list):
|
|
116
|
+
raise ValidationError(f"{key} must be an array")
|
|
117
|
+
if len(value) <= max_length:
|
|
118
|
+
return
|
|
119
|
+
raise ValidationError(f"{key} accepts at most {max_length} items")
|
|
120
|
+
|
|
121
|
+
def _validate_video_list(self, items: List[Dict[str, Any]]) -> None:
|
|
122
|
+
for index, item in enumerate(items):
|
|
123
|
+
url = item.get("url") if isinstance(item, dict) else None
|
|
124
|
+
if not url:
|
|
125
|
+
raise ValidationError(f"video_list[{index}].url is required")
|
|
126
|
+
start_time = self._to_float(item.get("start"))
|
|
127
|
+
end_time = self._to_float(item.get("ends"))
|
|
128
|
+
if start_time is None or end_time is None:
|
|
129
|
+
raise ValidationError(f"video_list[{index}] start and ends must be numbers")
|
|
130
|
+
if start_time < 0:
|
|
131
|
+
raise ValidationError(f"video_list[{index}].start must be 0 or greater")
|
|
132
|
+
if not end_time > start_time:
|
|
133
|
+
raise ValidationError(f"video_list[{index}].ends must be greater than start")
|
|
134
|
+
if (end_time - start_time) > self.MAX_TRIM_SECONDS:
|
|
135
|
+
raise ValidationError(
|
|
136
|
+
f"video_list[{index}] trim range must be {self.MAX_TRIM_SECONDS} seconds or less"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _validate_reference_units(self, params: Dict[str, Any]) -> None:
|
|
140
|
+
units = (
|
|
141
|
+
self._count(params.get("reference_image_urls"))
|
|
142
|
+
+ self._count(params.get("video_list")) * self.VIDEO_REFERENCE_UNITS
|
|
143
|
+
+ self._count(params.get("character_ids"))
|
|
144
|
+
)
|
|
145
|
+
if units <= self.REFERENCE_UNITS_MAX:
|
|
146
|
+
return
|
|
147
|
+
raise ValidationError(
|
|
148
|
+
f"reference_image_urls + video_list*2 + character_ids must use {self.REFERENCE_UNITS_MAX} reference units or fewer"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _validate_seed(params: Dict[str, Any]) -> None:
|
|
153
|
+
value = params.get("seed")
|
|
154
|
+
if value is None:
|
|
155
|
+
return
|
|
156
|
+
try:
|
|
157
|
+
seed = int(value)
|
|
158
|
+
except (TypeError, ValueError):
|
|
159
|
+
seed = None
|
|
160
|
+
if seed is not None and SEED_MIN <= seed <= SEED_MAX:
|
|
161
|
+
return
|
|
162
|
+
raise ValidationError(f"seed must be an integer between {SEED_MIN} and {SEED_MAX}")
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _count(value: Any) -> int:
|
|
166
|
+
if value is None:
|
|
167
|
+
return 0
|
|
168
|
+
return len(value) if isinstance(value, list) else 1
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _to_float(value: Any) -> Optional[float]:
|
|
172
|
+
if isinstance(value, bool):
|
|
173
|
+
return None
|
|
174
|
+
try:
|
|
175
|
+
return float(value)
|
|
176
|
+
except (TypeError, ValueError):
|
|
177
|
+
return None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Gemini Omni enums and response models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from runapi.core import BaseModel, TaskResponse, optional, required
|
|
6
|
+
|
|
7
|
+
AUDIO_VOICES = [
|
|
8
|
+
"achernar", "achird", "algenib", "algieba", "alnilam", "aoede", "autonoe",
|
|
9
|
+
"callirrhoe", "charon", "despina", "enceladus", "erinome", "fenrir", "gacrux",
|
|
10
|
+
"iapetus", "kore", "laomedeia", "leda", "orus", "puck", "pulcherrima",
|
|
11
|
+
"rasalgethi", "sadachbia", "sadaltager", "schedar", "sulafat", "umbriel",
|
|
12
|
+
"vindemiatrix", "zephyr", "zubenelgenubi",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
DURATIONS = [4, 6, 8, 10]
|
|
16
|
+
ASPECT_RATIOS = ["16:9", "9:16"]
|
|
17
|
+
OUTPUT_RESOLUTIONS = ["720p", "1080p", "4k"]
|
|
18
|
+
SEED_MIN = 0
|
|
19
|
+
SEED_MAX = 2_147_483_647
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Audio(BaseModel):
|
|
23
|
+
id = required(str)
|
|
24
|
+
name = optional(str)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CreateAudioResponse(BaseModel):
|
|
28
|
+
"""Result of creating a Gemini Omni reusable voice."""
|
|
29
|
+
id = required(str)
|
|
30
|
+
audio = optional(lambda: Audio)
|
|
31
|
+
error = optional(str)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Image(BaseModel):
|
|
35
|
+
url = optional(str)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Character(BaseModel):
|
|
39
|
+
id = required(str)
|
|
40
|
+
name = optional(str)
|
|
41
|
+
images = optional([lambda: Image])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CreateCharacterResponse(BaseModel):
|
|
45
|
+
"""Result of creating a Gemini Omni reusable character."""
|
|
46
|
+
id = required(str)
|
|
47
|
+
character = optional(lambda: Character)
|
|
48
|
+
error = optional(str)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Video(BaseModel):
|
|
52
|
+
url = optional(str)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TextToVideoResponse(TaskResponse):
|
|
56
|
+
"""Task status/result for Gemini Omni text-to-video."""
|
|
57
|
+
videos = optional([lambda: Video])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CompletedTextToVideoResponse(TextToVideoResponse):
|
|
61
|
+
"""Narrowed Gemini Omni text-to-video response once polling observes completion."""
|
|
62
|
+
videos = required([lambda: Video])
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from runapi.core import config
|
|
4
|
+
from runapi.core.errors import AuthenticationError, ValidationError
|
|
5
|
+
from runapi.gemini_omni import GeminiOmniClient
|
|
6
|
+
from runapi.gemini_omni.resources.create_audio import CreateAudio
|
|
7
|
+
from runapi.gemini_omni.resources.create_character import CreateCharacter
|
|
8
|
+
from runapi.gemini_omni.resources.text_to_video import TextToVideo
|
|
9
|
+
from runapi.gemini_omni.types import (
|
|
10
|
+
CompletedTextToVideoResponse,
|
|
11
|
+
CreateAudioResponse,
|
|
12
|
+
CreateCharacterResponse,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FakeHttp:
|
|
17
|
+
def __init__(self, *responses):
|
|
18
|
+
self._responses = list(responses)
|
|
19
|
+
self.calls = []
|
|
20
|
+
|
|
21
|
+
def request(self, method, path, body=None, options=None):
|
|
22
|
+
self.calls.append((method, path, body))
|
|
23
|
+
if self._responses:
|
|
24
|
+
return self._responses.pop(0)
|
|
25
|
+
return {"id": "task_1", "status": "pending"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(autouse=True)
|
|
29
|
+
def reset_config(monkeypatch):
|
|
30
|
+
monkeypatch.delenv("RUNAPI_API_KEY", raising=False)
|
|
31
|
+
monkeypatch.setattr(config, "api_key", None)
|
|
32
|
+
yield
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --- auth -----------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_accepts_api_key_parameter():
|
|
39
|
+
assert isinstance(GeminiOmniClient(api_key="k", http_client=FakeHttp()), GeminiOmniClient)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_falls_back_to_global(monkeypatch):
|
|
43
|
+
monkeypatch.setattr(config, "api_key", "global-key")
|
|
44
|
+
assert isinstance(GeminiOmniClient(http_client=FakeHttp()), GeminiOmniClient)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_falls_back_to_env(monkeypatch):
|
|
48
|
+
monkeypatch.setenv("RUNAPI_API_KEY", "env-key")
|
|
49
|
+
assert isinstance(GeminiOmniClient(http_client=FakeHttp()), GeminiOmniClient)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_raises_without_api_key():
|
|
53
|
+
with pytest.raises(AuthenticationError, match="API key is required"):
|
|
54
|
+
GeminiOmniClient()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_uses_injected_http_client_and_accessors():
|
|
58
|
+
fake = FakeHttp()
|
|
59
|
+
client = GeminiOmniClient(api_key="k", http_client=fake)
|
|
60
|
+
assert isinstance(client.create_audio, CreateAudio)
|
|
61
|
+
assert isinstance(client.create_character, CreateCharacter)
|
|
62
|
+
assert isinstance(client.text_to_video, TextToVideo)
|
|
63
|
+
assert client.text_to_video._http is fake
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- synchronous resources ------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_create_audio_posts_once_and_returns_typed():
|
|
70
|
+
fake = FakeHttp({"id": "a1", "audio": {"id": "kore", "name": "Narrator"}})
|
|
71
|
+
client = GeminiOmniClient(api_key="k", http_client=fake)
|
|
72
|
+
result = client.create_audio.run(audio_id="kore", name="Narrator")
|
|
73
|
+
assert fake.calls == [("post", "/api/v1/gemini_omni/create_audio", {"audio_id": "kore", "name": "Narrator"})]
|
|
74
|
+
assert isinstance(result, CreateAudioResponse)
|
|
75
|
+
assert result.audio.id == "kore"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_create_audio_requires_audio_id_and_name():
|
|
79
|
+
client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
|
|
80
|
+
with pytest.raises(ValidationError, match="audio_id is required"):
|
|
81
|
+
client.create_audio.run(name="Narrator")
|
|
82
|
+
with pytest.raises(ValidationError, match="name is required"):
|
|
83
|
+
client.create_audio.run(audio_id="kore")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_create_audio_name_length():
|
|
87
|
+
client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
|
|
88
|
+
with pytest.raises(ValidationError, match="name must be at most 210 characters"):
|
|
89
|
+
client.create_audio.run(audio_id="kore", name="x" * 211)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_create_character_returns_typed_and_validates():
|
|
93
|
+
fake = FakeHttp({"id": "c1", "character": {"id": "c1", "name": "Robot"}})
|
|
94
|
+
client = GeminiOmniClient(api_key="k", http_client=fake)
|
|
95
|
+
result = client.create_character.run(descriptions="A robot", reference_image_url="https://x/r.png")
|
|
96
|
+
assert fake.calls[0][0:2] == ("post", "/api/v1/gemini_omni/create_character")
|
|
97
|
+
assert isinstance(result, CreateCharacterResponse)
|
|
98
|
+
assert result.character.id == "c1"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_create_character_requires_fields():
|
|
102
|
+
client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
|
|
103
|
+
with pytest.raises(ValidationError, match="descriptions is required"):
|
|
104
|
+
client.create_character.run(reference_image_url="https://x/r.png")
|
|
105
|
+
with pytest.raises(ValidationError, match="reference_image_url is required"):
|
|
106
|
+
client.create_character.run(descriptions="A robot")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_create_character_audio_ids_must_be_array():
|
|
110
|
+
client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
|
|
111
|
+
with pytest.raises(ValidationError, match="audio_ids must be an array"):
|
|
112
|
+
client.create_character.run(
|
|
113
|
+
descriptions="A robot", reference_image_url="https://x/r.png", audio_ids="not-a-list"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --- async text_to_video --------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_text_to_video_create_and_get_shapes():
|
|
121
|
+
fake = FakeHttp({"id": "t1", "status": "pending"}, {"id": "t1", "status": "processing"})
|
|
122
|
+
client = GeminiOmniClient(api_key="k", http_client=fake)
|
|
123
|
+
client.text_to_video.create(prompt="a fox", duration_seconds=8)
|
|
124
|
+
client.text_to_video.get("t1")
|
|
125
|
+
assert fake.calls == [
|
|
126
|
+
("post", "/api/v1/gemini_omni/text_to_video", {"prompt": "a fox", "duration_seconds": 8}),
|
|
127
|
+
("get", "/api/v1/gemini_omni/text_to_video/t1", None),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_text_to_video_requires_prompt_and_duration():
|
|
132
|
+
client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
|
|
133
|
+
with pytest.raises(ValidationError, match="prompt is required"):
|
|
134
|
+
client.text_to_video.create(duration_seconds=8)
|
|
135
|
+
with pytest.raises(ValidationError, match="duration_seconds is required"):
|
|
136
|
+
client.text_to_video.create(prompt="a fox")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_text_to_video_duration_enum():
|
|
140
|
+
client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
|
|
141
|
+
with pytest.raises(ValidationError, match="Invalid duration_seconds"):
|
|
142
|
+
client.text_to_video.create(prompt="a fox", duration_seconds=7)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_text_to_video_reference_units_cap():
|
|
146
|
+
client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
|
|
147
|
+
with pytest.raises(ValidationError, match="reference units"):
|
|
148
|
+
client.text_to_video.create(
|
|
149
|
+
prompt="a fox",
|
|
150
|
+
duration_seconds=8,
|
|
151
|
+
reference_image_urls=["a", "b", "c", "d", "e"], # 5, within array max (7)
|
|
152
|
+
character_ids=["x", "y", "z"], # 3, within array max (3); 5 + 3 = 8 > 7 units
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_text_to_video_run_narrows_completed():
|
|
157
|
+
fake = FakeHttp(
|
|
158
|
+
{"id": "t1", "status": "pending"},
|
|
159
|
+
{"id": "t1", "status": "completed", "videos": [{"url": "https://x/v.mp4"}]},
|
|
160
|
+
)
|
|
161
|
+
client = GeminiOmniClient(api_key="k", http_client=fake)
|
|
162
|
+
result = client.text_to_video.run(prompt="a fox", duration_seconds=8)
|
|
163
|
+
assert isinstance(result, CompletedTextToVideoResponse)
|
|
164
|
+
assert result.videos[0].url == "https://x/v.mp4"
|