genblaze-google 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.
- genblaze_google-0.1.0/.gitignore +76 -0
- genblaze_google-0.1.0/PKG-INFO +128 -0
- genblaze_google-0.1.0/README.md +110 -0
- genblaze_google-0.1.0/genblaze_google/__init__.py +6 -0
- genblaze_google-0.1.0/genblaze_google/_errors.py +19 -0
- genblaze_google-0.1.0/genblaze_google/imagen.py +162 -0
- genblaze_google-0.1.0/genblaze_google/provider.py +276 -0
- genblaze_google-0.1.0/genblaze_google/py.typed +0 -0
- genblaze_google-0.1.0/pyproject.toml +40 -0
- genblaze_google-0.1.0/tests/__init__.py +0 -0
- genblaze_google-0.1.0/tests/test_imagen_provider.py +175 -0
- genblaze_google-0.1.0/tests/test_veo_provider.py +324 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
|
|
7
|
+
# Distribution / packaging
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
*.egg
|
|
12
|
+
wheels/
|
|
13
|
+
/MANIFEST
|
|
14
|
+
|
|
15
|
+
# Virtual environments
|
|
16
|
+
.venv/
|
|
17
|
+
venv/
|
|
18
|
+
env/
|
|
19
|
+
ENV/
|
|
20
|
+
|
|
21
|
+
# IDE / editors
|
|
22
|
+
.vscode/
|
|
23
|
+
.idea/
|
|
24
|
+
*.swp
|
|
25
|
+
*.swo
|
|
26
|
+
*~
|
|
27
|
+
.project
|
|
28
|
+
.settings/
|
|
29
|
+
|
|
30
|
+
# Testing / coverage
|
|
31
|
+
.pytest_cache/
|
|
32
|
+
.coverage
|
|
33
|
+
.coverage.*
|
|
34
|
+
htmlcov/
|
|
35
|
+
coverage.xml
|
|
36
|
+
*.cover
|
|
37
|
+
|
|
38
|
+
# Type checking
|
|
39
|
+
.mypy_cache/
|
|
40
|
+
.pytype/
|
|
41
|
+
.pyre/
|
|
42
|
+
|
|
43
|
+
# Linting
|
|
44
|
+
.ruff_cache/
|
|
45
|
+
|
|
46
|
+
# OS files
|
|
47
|
+
.DS_Store
|
|
48
|
+
Thumbs.db
|
|
49
|
+
ehthumbs.db
|
|
50
|
+
|
|
51
|
+
# Environment / secrets
|
|
52
|
+
.env
|
|
53
|
+
.env.*
|
|
54
|
+
!.env.example
|
|
55
|
+
*.pem
|
|
56
|
+
*.key
|
|
57
|
+
credentials.json
|
|
58
|
+
|
|
59
|
+
# Jupyter
|
|
60
|
+
.ipynb_checkpoints/
|
|
61
|
+
|
|
62
|
+
# MkDocs build output
|
|
63
|
+
site/
|
|
64
|
+
|
|
65
|
+
# Claude Code — local/ephemeral
|
|
66
|
+
.claude/settings.local.json
|
|
67
|
+
.claude/worktrees/
|
|
68
|
+
CLAUDE.local.md
|
|
69
|
+
|
|
70
|
+
# Hypothesis test cache
|
|
71
|
+
.hypothesis/
|
|
72
|
+
|
|
73
|
+
# Temp files
|
|
74
|
+
*.tmp
|
|
75
|
+
*.bak
|
|
76
|
+
*.log
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: genblaze-google
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Google provider adapters for genblaze (Veo video, Imagen image)
|
|
5
|
+
Project-URL: Documentation, https://github.com/backblaze-labs/genblaze
|
|
6
|
+
Project-URL: Repository, https://github.com/backblaze-labs/genblaze
|
|
7
|
+
Project-URL: Issues, https://github.com/backblaze-labs/genblaze/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Requires-Dist: genblaze-core<0.2,>=0.1.0
|
|
14
|
+
Requires-Dist: google-genai>=1.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
<!-- last_verified: 2026-04-22 -->
|
|
20
|
+
# genblaze-google
|
|
21
|
+
|
|
22
|
+
**Google provider adapters for [genblaze](https://github.com/backblaze-labs/genblaze) — [Veo](https://deepmind.google/technologies/veo/) text-to-video and [Imagen](https://deepmind.google/technologies/imagen-3/) text-to-image — with SHA-256 provenance manifests on every output.**
|
|
23
|
+
|
|
24
|
+
`genblaze-google` wraps Google's generative media models (Veo 2, Veo 3, Imagen 3) as genblaze providers via the unified `google-genai` SDK. Works with both Gemini API keys and Google Cloud Vertex AI authentication. Compose Veo/Imagen calls into multi-step AI pipelines, persist outputs to [Backblaze B2](https://www.backblaze.com/cloud-storage?utm_source=github&utm_medium=referral&utm_campaign=ai_artifacts&utm_content=genblaze) or any S3-compatible store, and emit a tamper-evident provenance manifest for every run.
|
|
25
|
+
|
|
26
|
+
## Why genblaze-google
|
|
27
|
+
|
|
28
|
+
- **Veo 3 with synchronized audio** — text-to-video + native audio, wrapped in a provenance manifest.
|
|
29
|
+
- **Imagen 3 high-fidelity images** — photorealistic stills with full parameter tracking.
|
|
30
|
+
- **Two auth modes** — Gemini API (`GEMINI_API_KEY`) for quick start, Vertex AI for enterprise / GCP orgs.
|
|
31
|
+
- **Same SDK, any provider** — swap to Sora, Runway, Luma, Flux without rewriting pipeline logic.
|
|
32
|
+
- **Provenance by default** — SHA-256 hash + canonical manifest on every generation.
|
|
33
|
+
- **Durable storage** — plug `genblaze-s3` in for Backblaze B2 / AWS S3 / Cloudflare R2 / MinIO.
|
|
34
|
+
|
|
35
|
+
## Providers + models
|
|
36
|
+
|
|
37
|
+
| Provider class | Modality | Models |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `VeoProvider` | video | `veo-3.0-generate-001` (with audio), `veo-3.0-fast-generate-001`, `veo-2.0-generate-001` |
|
|
40
|
+
| `ImagenProvider` | image | `imagen-3.0-generate-002`, `imagen-3.0-fast-generate-001` |
|
|
41
|
+
|
|
42
|
+
Each is registered via entry points (`google-veo`, `google-imagen`).
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install genblaze-google
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quickstart — Veo 3 text-to-video (with audio)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export GEMINI_API_KEY="..." # or use Vertex AI auth
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from genblaze_core import Modality, Pipeline
|
|
58
|
+
from genblaze_google import VeoProvider
|
|
59
|
+
|
|
60
|
+
run, manifest = (
|
|
61
|
+
Pipeline("veo-demo")
|
|
62
|
+
.step(VeoProvider(), model="veo-3.0-generate-001",
|
|
63
|
+
prompt="A time-lapse of a coral reef coming to life, colorful fish "
|
|
64
|
+
"swimming through vibrant coral, natural ocean lighting",
|
|
65
|
+
modality=Modality.VIDEO,
|
|
66
|
+
aspect_ratio="16:9", duration_seconds="8", resolution="720p",
|
|
67
|
+
enhance_prompt=True)
|
|
68
|
+
.run(timeout=600)
|
|
69
|
+
)
|
|
70
|
+
print(run.steps[0].assets[0].url, manifest.canonical_hash)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Vertex AI auth instead:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
provider = VeoProvider(project="my-gcp-project", location="us-central1")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Quickstart — Imagen 3 text-to-image
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from genblaze_google import ImagenProvider
|
|
83
|
+
|
|
84
|
+
run, manifest = (
|
|
85
|
+
Pipeline("imagen-demo")
|
|
86
|
+
.step(ImagenProvider(output_dir="output/images"),
|
|
87
|
+
model="imagen-3.0-generate-002",
|
|
88
|
+
prompt="A photorealistic aerial view of a coral reef teeming with tropical fish",
|
|
89
|
+
modality=Modality.IMAGE, aspect_ratio="16:9")
|
|
90
|
+
.run(timeout=120)
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Persist to Backblaze B2
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from genblaze_core import KeyStrategy, ObjectStorageSink
|
|
98
|
+
from genblaze_s3 import S3StorageBackend
|
|
99
|
+
|
|
100
|
+
storage = ObjectStorageSink(
|
|
101
|
+
S3StorageBackend.for_backblaze("my-bucket"),
|
|
102
|
+
key_strategy=KeyStrategy.HIERARCHICAL,
|
|
103
|
+
)
|
|
104
|
+
# pass sink=storage to .run(…) to push assets + manifest to B2
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
[Backblaze B2](https://www.backblaze.com/cloud-storage?utm_source=github&utm_medium=referral&utm_campaign=ai_artifacts&utm_content=genblaze) is the recommended default sink for genblaze — cost-efficient, S3-compatible, with Object Lock for tamper-evident manifests.
|
|
108
|
+
|
|
109
|
+
## Credentials
|
|
110
|
+
|
|
111
|
+
| Auth mode | Env var / config |
|
|
112
|
+
|---|---|
|
|
113
|
+
| Gemini API (quickest) | `GEMINI_API_KEY` — https://aistudio.google.com/apikey |
|
|
114
|
+
| Vertex AI | `VeoProvider(project=..., location=...)` + `gcloud auth application-default login` |
|
|
115
|
+
|
|
116
|
+
## Documentation
|
|
117
|
+
|
|
118
|
+
- **Main repo**: https://github.com/backblaze-labs/genblaze
|
|
119
|
+
- **Examples**: [`veo_video_pipeline.py`](https://github.com/backblaze-labs/genblaze/blob/main/examples/veo_video_pipeline.py) · [`imagen_pipeline.py`](https://github.com/backblaze-labs/genblaze/blob/main/examples/imagen_pipeline.py)
|
|
120
|
+
|
|
121
|
+
## Related packages
|
|
122
|
+
|
|
123
|
+
- [`genblaze-core`](https://pypi.org/project/genblaze-core/) — the pipeline SDK
|
|
124
|
+
- [`genblaze-s3`](https://pypi.org/project/genblaze-s3/) — durable storage on Backblaze B2 and other S3-compatible backends
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<!-- last_verified: 2026-04-22 -->
|
|
2
|
+
# genblaze-google
|
|
3
|
+
|
|
4
|
+
**Google provider adapters for [genblaze](https://github.com/backblaze-labs/genblaze) — [Veo](https://deepmind.google/technologies/veo/) text-to-video and [Imagen](https://deepmind.google/technologies/imagen-3/) text-to-image — with SHA-256 provenance manifests on every output.**
|
|
5
|
+
|
|
6
|
+
`genblaze-google` wraps Google's generative media models (Veo 2, Veo 3, Imagen 3) as genblaze providers via the unified `google-genai` SDK. Works with both Gemini API keys and Google Cloud Vertex AI authentication. Compose Veo/Imagen calls into multi-step AI pipelines, persist outputs to [Backblaze B2](https://www.backblaze.com/cloud-storage?utm_source=github&utm_medium=referral&utm_campaign=ai_artifacts&utm_content=genblaze) or any S3-compatible store, and emit a tamper-evident provenance manifest for every run.
|
|
7
|
+
|
|
8
|
+
## Why genblaze-google
|
|
9
|
+
|
|
10
|
+
- **Veo 3 with synchronized audio** — text-to-video + native audio, wrapped in a provenance manifest.
|
|
11
|
+
- **Imagen 3 high-fidelity images** — photorealistic stills with full parameter tracking.
|
|
12
|
+
- **Two auth modes** — Gemini API (`GEMINI_API_KEY`) for quick start, Vertex AI for enterprise / GCP orgs.
|
|
13
|
+
- **Same SDK, any provider** — swap to Sora, Runway, Luma, Flux without rewriting pipeline logic.
|
|
14
|
+
- **Provenance by default** — SHA-256 hash + canonical manifest on every generation.
|
|
15
|
+
- **Durable storage** — plug `genblaze-s3` in for Backblaze B2 / AWS S3 / Cloudflare R2 / MinIO.
|
|
16
|
+
|
|
17
|
+
## Providers + models
|
|
18
|
+
|
|
19
|
+
| Provider class | Modality | Models |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `VeoProvider` | video | `veo-3.0-generate-001` (with audio), `veo-3.0-fast-generate-001`, `veo-2.0-generate-001` |
|
|
22
|
+
| `ImagenProvider` | image | `imagen-3.0-generate-002`, `imagen-3.0-fast-generate-001` |
|
|
23
|
+
|
|
24
|
+
Each is registered via entry points (`google-veo`, `google-imagen`).
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install genblaze-google
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quickstart — Veo 3 text-to-video (with audio)
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
export GEMINI_API_KEY="..." # or use Vertex AI auth
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from genblaze_core import Modality, Pipeline
|
|
40
|
+
from genblaze_google import VeoProvider
|
|
41
|
+
|
|
42
|
+
run, manifest = (
|
|
43
|
+
Pipeline("veo-demo")
|
|
44
|
+
.step(VeoProvider(), model="veo-3.0-generate-001",
|
|
45
|
+
prompt="A time-lapse of a coral reef coming to life, colorful fish "
|
|
46
|
+
"swimming through vibrant coral, natural ocean lighting",
|
|
47
|
+
modality=Modality.VIDEO,
|
|
48
|
+
aspect_ratio="16:9", duration_seconds="8", resolution="720p",
|
|
49
|
+
enhance_prompt=True)
|
|
50
|
+
.run(timeout=600)
|
|
51
|
+
)
|
|
52
|
+
print(run.steps[0].assets[0].url, manifest.canonical_hash)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Vertex AI auth instead:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
provider = VeoProvider(project="my-gcp-project", location="us-central1")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quickstart — Imagen 3 text-to-image
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from genblaze_google import ImagenProvider
|
|
65
|
+
|
|
66
|
+
run, manifest = (
|
|
67
|
+
Pipeline("imagen-demo")
|
|
68
|
+
.step(ImagenProvider(output_dir="output/images"),
|
|
69
|
+
model="imagen-3.0-generate-002",
|
|
70
|
+
prompt="A photorealistic aerial view of a coral reef teeming with tropical fish",
|
|
71
|
+
modality=Modality.IMAGE, aspect_ratio="16:9")
|
|
72
|
+
.run(timeout=120)
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Persist to Backblaze B2
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from genblaze_core import KeyStrategy, ObjectStorageSink
|
|
80
|
+
from genblaze_s3 import S3StorageBackend
|
|
81
|
+
|
|
82
|
+
storage = ObjectStorageSink(
|
|
83
|
+
S3StorageBackend.for_backblaze("my-bucket"),
|
|
84
|
+
key_strategy=KeyStrategy.HIERARCHICAL,
|
|
85
|
+
)
|
|
86
|
+
# pass sink=storage to .run(…) to push assets + manifest to B2
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
[Backblaze B2](https://www.backblaze.com/cloud-storage?utm_source=github&utm_medium=referral&utm_campaign=ai_artifacts&utm_content=genblaze) is the recommended default sink for genblaze — cost-efficient, S3-compatible, with Object Lock for tamper-evident manifests.
|
|
90
|
+
|
|
91
|
+
## Credentials
|
|
92
|
+
|
|
93
|
+
| Auth mode | Env var / config |
|
|
94
|
+
|---|---|
|
|
95
|
+
| Gemini API (quickest) | `GEMINI_API_KEY` — https://aistudio.google.com/apikey |
|
|
96
|
+
| Vertex AI | `VeoProvider(project=..., location=...)` + `gcloud auth application-default login` |
|
|
97
|
+
|
|
98
|
+
## Documentation
|
|
99
|
+
|
|
100
|
+
- **Main repo**: https://github.com/backblaze-labs/genblaze
|
|
101
|
+
- **Examples**: [`veo_video_pipeline.py`](https://github.com/backblaze-labs/genblaze/blob/main/examples/veo_video_pipeline.py) · [`imagen_pipeline.py`](https://github.com/backblaze-labs/genblaze/blob/main/examples/imagen_pipeline.py)
|
|
102
|
+
|
|
103
|
+
## Related packages
|
|
104
|
+
|
|
105
|
+
- [`genblaze-core`](https://pypi.org/project/genblaze-core/) — the pipeline SDK
|
|
106
|
+
- [`genblaze-s3`](https://pypi.org/project/genblaze-s3/) — durable storage on Backblaze B2 and other S3-compatible backends
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared Google API error mapping — used by provider.py, imagen.py."""
|
|
2
|
+
|
|
3
|
+
from genblaze_core.models.enums import ProviderErrorCode
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def map_google_error(exc: Exception) -> ProviderErrorCode:
|
|
7
|
+
"""Map a Google API exception to a ProviderErrorCode."""
|
|
8
|
+
msg = str(exc).lower()
|
|
9
|
+
if "rate" in msg or "429" in msg or "resource_exhausted" in msg:
|
|
10
|
+
return ProviderErrorCode.RATE_LIMIT
|
|
11
|
+
if "auth" in msg or "401" in msg or "403" in msg or "permission" in msg:
|
|
12
|
+
return ProviderErrorCode.AUTH_FAILURE
|
|
13
|
+
if "invalid" in msg or "400" in msg:
|
|
14
|
+
return ProviderErrorCode.INVALID_INPUT
|
|
15
|
+
if "timeout" in msg or "deadline" in msg:
|
|
16
|
+
return ProviderErrorCode.TIMEOUT
|
|
17
|
+
if "500" in msg or "unavailable" in msg or "internal" in msg:
|
|
18
|
+
return ProviderErrorCode.SERVER_ERROR
|
|
19
|
+
return ProviderErrorCode.UNKNOWN
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""ImagenProvider — adapter for Google Imagen image generation.
|
|
2
|
+
|
|
3
|
+
Synchronous API: client.models.generate_images() returns images directly.
|
|
4
|
+
|
|
5
|
+
Docs: https://ai.google.dev/gemini-api/docs/image-generation
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
from genblaze_core.exceptions import ProviderError
|
|
17
|
+
from genblaze_core.models.asset import Asset
|
|
18
|
+
from genblaze_core.models.enums import Modality, ProviderErrorCode
|
|
19
|
+
from genblaze_core.models.step import Step
|
|
20
|
+
from genblaze_core.providers.base import ProviderCapabilities, SyncProvider
|
|
21
|
+
from genblaze_core.runnable.config import RunnableConfig
|
|
22
|
+
|
|
23
|
+
from genblaze_google._errors import map_google_error
|
|
24
|
+
|
|
25
|
+
_VALID_ASPECT_RATIOS = {"1:1", "3:4", "4:3", "9:16", "16:9"}
|
|
26
|
+
|
|
27
|
+
# Per-image pricing by model (USD)
|
|
28
|
+
_IMAGEN_PRICING: dict[str, float] = {
|
|
29
|
+
"imagen-3.0-generate-002": 0.04,
|
|
30
|
+
"imagen-3.0-fast-generate-001": 0.02,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ImagenProvider(SyncProvider):
|
|
35
|
+
"""Provider adapter for Google Imagen image generation.
|
|
36
|
+
|
|
37
|
+
Models: ``imagen-3.0-generate-002`` (latest), ``imagen-3.0-fast-generate-001``.
|
|
38
|
+
|
|
39
|
+
Imagen returns image bytes directly (synchronous, not operation-based).
|
|
40
|
+
Output is saved to files; use ObjectStorageSink for cloud upload.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
api_key: Gemini API key. Falls back to GEMINI_API_KEY env var.
|
|
44
|
+
project: GCP project ID for Vertex AI auth.
|
|
45
|
+
location: GCP region for Vertex AI (default "us-central1").
|
|
46
|
+
output_dir: Directory for output image files (default system temp).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name = "google-imagen"
|
|
50
|
+
|
|
51
|
+
def get_capabilities(self) -> ProviderCapabilities:
|
|
52
|
+
"""Imagen: image generation from text prompts."""
|
|
53
|
+
return ProviderCapabilities(
|
|
54
|
+
supported_modalities=[Modality.IMAGE],
|
|
55
|
+
supported_inputs=["text"],
|
|
56
|
+
models=["imagen-3.0-generate-002", "imagen-3.0-fast-generate-001"],
|
|
57
|
+
output_formats=["image/png", "image/jpeg"],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
api_key: str | None = None,
|
|
63
|
+
*,
|
|
64
|
+
project: str | None = None,
|
|
65
|
+
location: str = "us-central1",
|
|
66
|
+
output_dir: str | Path | None = None,
|
|
67
|
+
):
|
|
68
|
+
super().__init__()
|
|
69
|
+
self._api_key = api_key
|
|
70
|
+
self._project = project
|
|
71
|
+
self._location = location
|
|
72
|
+
self._output_dir = Path(output_dir) if output_dir else None
|
|
73
|
+
self._client: Any = None
|
|
74
|
+
|
|
75
|
+
def _get_client(self):
|
|
76
|
+
if self._client is None:
|
|
77
|
+
try:
|
|
78
|
+
from google import genai
|
|
79
|
+
except ImportError as exc:
|
|
80
|
+
raise ProviderError(
|
|
81
|
+
"google-genai package not installed. Run: pip install google-genai"
|
|
82
|
+
) from exc
|
|
83
|
+
if self._project:
|
|
84
|
+
self._client = genai.Client(
|
|
85
|
+
vertexai=True, project=self._project, location=self._location
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
kwargs: dict = {}
|
|
89
|
+
if self._api_key:
|
|
90
|
+
kwargs["api_key"] = self._api_key
|
|
91
|
+
self._client = genai.Client(**kwargs)
|
|
92
|
+
return self._client
|
|
93
|
+
|
|
94
|
+
def generate(self, step: Step, config: RunnableConfig | None = None) -> Step:
|
|
95
|
+
"""Generate image(s) via the Google Imagen API."""
|
|
96
|
+
client = self._get_client()
|
|
97
|
+
try:
|
|
98
|
+
from google.genai import types
|
|
99
|
+
|
|
100
|
+
config_kwargs: dict = {}
|
|
101
|
+
|
|
102
|
+
if "number_of_images" in step.params:
|
|
103
|
+
config_kwargs["number_of_images"] = int(step.params["number_of_images"])
|
|
104
|
+
if "aspect_ratio" in step.params:
|
|
105
|
+
ar = step.params["aspect_ratio"]
|
|
106
|
+
if ar not in _VALID_ASPECT_RATIOS:
|
|
107
|
+
raise ProviderError(
|
|
108
|
+
f"Invalid aspect_ratio={ar!r}. Must be one of {_VALID_ASPECT_RATIOS}",
|
|
109
|
+
error_code=ProviderErrorCode.INVALID_INPUT,
|
|
110
|
+
)
|
|
111
|
+
config_kwargs["aspect_ratio"] = ar
|
|
112
|
+
if "person_generation" in step.params:
|
|
113
|
+
config_kwargs["person_generation"] = step.params["person_generation"]
|
|
114
|
+
if "safety_filter_level" in step.params:
|
|
115
|
+
config_kwargs["safety_filter_level"] = step.params["safety_filter_level"]
|
|
116
|
+
if "output_mime_type" in step.params:
|
|
117
|
+
config_kwargs["output_mime_type"] = step.params["output_mime_type"]
|
|
118
|
+
|
|
119
|
+
gen_config = types.GenerateImagesConfig(**config_kwargs) if config_kwargs else None
|
|
120
|
+
|
|
121
|
+
kwargs: dict = {"model": step.model, "prompt": step.prompt or ""}
|
|
122
|
+
if gen_config is not None:
|
|
123
|
+
kwargs["config"] = gen_config
|
|
124
|
+
|
|
125
|
+
response = client.models.generate_images(**kwargs)
|
|
126
|
+
|
|
127
|
+
# Safety filter can return 0 images without raising an error
|
|
128
|
+
if not response.generated_images:
|
|
129
|
+
raise ProviderError(
|
|
130
|
+
"Imagen returned no images — prompt may have been blocked by safety filters",
|
|
131
|
+
error_code=ProviderErrorCode.INVALID_INPUT,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
mime_type = step.params.get("output_mime_type", "image/png")
|
|
135
|
+
suffix = ".jpeg" if "jpeg" in mime_type else ".png"
|
|
136
|
+
|
|
137
|
+
for i, img in enumerate(response.generated_images):
|
|
138
|
+
if self._output_dir:
|
|
139
|
+
self._output_dir.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
out_path = self._output_dir / f"{step.step_id}_{i}{suffix}"
|
|
141
|
+
else:
|
|
142
|
+
fd, tmp = tempfile.mkstemp(suffix=suffix)
|
|
143
|
+
os.close(fd)
|
|
144
|
+
out_path = Path(tmp)
|
|
145
|
+
|
|
146
|
+
img.image.save(str(out_path))
|
|
147
|
+
file_url = f"file://{quote(str(out_path.resolve()))}"
|
|
148
|
+
step.assets.append(Asset(url=file_url, media_type=mime_type))
|
|
149
|
+
|
|
150
|
+
# Track cost
|
|
151
|
+
price = _IMAGEN_PRICING.get(step.model)
|
|
152
|
+
if price is not None:
|
|
153
|
+
step.cost_usd = price * len(step.assets)
|
|
154
|
+
|
|
155
|
+
return step
|
|
156
|
+
except ProviderError:
|
|
157
|
+
raise
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
raise ProviderError(
|
|
160
|
+
f"Imagen generation failed: {exc}",
|
|
161
|
+
error_code=map_google_error(exc),
|
|
162
|
+
) from exc
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""VeoProvider — adapter for Google Veo video generation.
|
|
2
|
+
|
|
3
|
+
Uses the google-genai SDK with the async operation-based workflow:
|
|
4
|
+
client.models.generate_videos() → poll operation → download video
|
|
5
|
+
|
|
6
|
+
Docs: https://ai.google.dev/gemini-api/docs/video
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from genblaze_core.exceptions import ProviderError
|
|
14
|
+
from genblaze_core.models.asset import Asset, AudioMetadata, Track, VideoMetadata
|
|
15
|
+
from genblaze_core.models.enums import Modality, ProviderErrorCode
|
|
16
|
+
from genblaze_core.models.step import Step
|
|
17
|
+
from genblaze_core.providers.base import BaseProvider, ProviderCapabilities, validate_asset_url
|
|
18
|
+
from genblaze_core.runnable.config import RunnableConfig
|
|
19
|
+
|
|
20
|
+
from genblaze_google._errors import map_google_error
|
|
21
|
+
|
|
22
|
+
# Valid Veo parameter values
|
|
23
|
+
_VALID_ASPECT_RATIOS = {"16:9", "9:16"}
|
|
24
|
+
_VALID_RESOLUTIONS = {"720p", "1080p", "4k"}
|
|
25
|
+
_VALID_DURATIONS = {"4", "6", "8"}
|
|
26
|
+
|
|
27
|
+
# Per-second pricing by model (USD)
|
|
28
|
+
_VEO_PRICING: dict[str, float] = {
|
|
29
|
+
"veo-2.0-generate-001": 0.35,
|
|
30
|
+
"veo-3.0-generate-001": 0.50,
|
|
31
|
+
"veo-3.0-fast-generate-001": 0.25,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class VeoProvider(BaseProvider):
|
|
36
|
+
"""Provider adapter for Google Veo video generation.
|
|
37
|
+
|
|
38
|
+
Models: ``veo-2.0-generate-001`` (stable), ``veo-3.0-generate-001`` (GA, audio),
|
|
39
|
+
``veo-3.0-fast-generate-001`` (GA, fast).
|
|
40
|
+
|
|
41
|
+
Supports both Gemini API (api_key) and Vertex AI (project/location) auth.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
api_key: Gemini API key. Falls back to GEMINI_API_KEY env var.
|
|
45
|
+
project: GCP project ID for Vertex AI auth (mutually exclusive with api_key).
|
|
46
|
+
location: GCP region for Vertex AI (default "us-central1").
|
|
47
|
+
poll_interval: Seconds between operation polls (default 10).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
name = "google-veo"
|
|
51
|
+
|
|
52
|
+
def get_capabilities(self) -> ProviderCapabilities:
|
|
53
|
+
"""Veo: video generation from text prompts with configurable resolution and duration."""
|
|
54
|
+
return ProviderCapabilities(
|
|
55
|
+
supported_modalities=[Modality.VIDEO],
|
|
56
|
+
supported_inputs=["text"],
|
|
57
|
+
max_duration=8.0,
|
|
58
|
+
resolutions=["720p", "1080p", "4k"],
|
|
59
|
+
models=[
|
|
60
|
+
"veo-2.0-generate-001",
|
|
61
|
+
"veo-3.0-generate-001",
|
|
62
|
+
"veo-3.0-fast-generate-001",
|
|
63
|
+
],
|
|
64
|
+
output_formats=["video/mp4"],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
api_key: str | None = None,
|
|
70
|
+
*,
|
|
71
|
+
project: str | None = None,
|
|
72
|
+
location: str = "us-central1",
|
|
73
|
+
poll_interval: float = 10.0,
|
|
74
|
+
):
|
|
75
|
+
super().__init__()
|
|
76
|
+
self.poll_interval = poll_interval
|
|
77
|
+
self._api_key = api_key
|
|
78
|
+
self._project = project
|
|
79
|
+
self._location = location
|
|
80
|
+
self._client: Any = None
|
|
81
|
+
|
|
82
|
+
def normalize_params(self, params: dict, modality: Any = None) -> dict:
|
|
83
|
+
"""Map standard params to Veo-native names."""
|
|
84
|
+
p = dict(params)
|
|
85
|
+
# duration → duration_seconds (Veo's native key)
|
|
86
|
+
if "duration" in p and "duration_seconds" not in p:
|
|
87
|
+
p["duration_seconds"] = p.pop("duration")
|
|
88
|
+
return p
|
|
89
|
+
|
|
90
|
+
def _get_client(self):
|
|
91
|
+
if self._client is None:
|
|
92
|
+
try:
|
|
93
|
+
from google import genai
|
|
94
|
+
except ImportError as exc:
|
|
95
|
+
raise ProviderError(
|
|
96
|
+
"google-genai package not installed. Run: pip install google-genai"
|
|
97
|
+
) from exc
|
|
98
|
+
|
|
99
|
+
if self._project:
|
|
100
|
+
# Vertex AI auth
|
|
101
|
+
self._client = genai.Client(
|
|
102
|
+
vertexai=True,
|
|
103
|
+
project=self._project,
|
|
104
|
+
location=self._location,
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
# Gemini API key auth
|
|
108
|
+
kwargs: dict = {}
|
|
109
|
+
if self._api_key:
|
|
110
|
+
kwargs["api_key"] = self._api_key
|
|
111
|
+
self._client = genai.Client(**kwargs)
|
|
112
|
+
return self._client
|
|
113
|
+
|
|
114
|
+
def _build_config(self, step: Step) -> Any:
|
|
115
|
+
"""Build a GenerateVideosConfig from step.params."""
|
|
116
|
+
from google.genai import types
|
|
117
|
+
|
|
118
|
+
config_kwargs: dict = {}
|
|
119
|
+
|
|
120
|
+
if "aspect_ratio" in step.params:
|
|
121
|
+
ar = step.params["aspect_ratio"]
|
|
122
|
+
if ar not in _VALID_ASPECT_RATIOS:
|
|
123
|
+
raise ProviderError(
|
|
124
|
+
f"Invalid aspect_ratio={ar!r}. Must be one of {_VALID_ASPECT_RATIOS}",
|
|
125
|
+
error_code=ProviderErrorCode.INVALID_INPUT,
|
|
126
|
+
)
|
|
127
|
+
config_kwargs["aspect_ratio"] = ar
|
|
128
|
+
|
|
129
|
+
if "resolution" in step.params:
|
|
130
|
+
res = step.params["resolution"]
|
|
131
|
+
if res not in _VALID_RESOLUTIONS:
|
|
132
|
+
raise ProviderError(
|
|
133
|
+
f"Invalid resolution={res!r}. Must be one of {_VALID_RESOLUTIONS}",
|
|
134
|
+
error_code=ProviderErrorCode.INVALID_INPUT,
|
|
135
|
+
)
|
|
136
|
+
config_kwargs["resolution"] = res
|
|
137
|
+
|
|
138
|
+
# Duration (normalize_params already mapped duration → duration_seconds)
|
|
139
|
+
raw_dur = step.params.get("duration_seconds")
|
|
140
|
+
if raw_dur is not None:
|
|
141
|
+
dur = str(raw_dur)
|
|
142
|
+
if dur not in _VALID_DURATIONS:
|
|
143
|
+
raise ProviderError(
|
|
144
|
+
f"Invalid duration_seconds={dur!r}. Must be one of {_VALID_DURATIONS}",
|
|
145
|
+
error_code=ProviderErrorCode.INVALID_INPUT,
|
|
146
|
+
)
|
|
147
|
+
config_kwargs["duration_seconds"] = dur
|
|
148
|
+
|
|
149
|
+
if "person_generation" in step.params:
|
|
150
|
+
config_kwargs["person_generation"] = step.params["person_generation"]
|
|
151
|
+
|
|
152
|
+
if "number_of_videos" in step.params:
|
|
153
|
+
config_kwargs["number_of_videos"] = int(step.params["number_of_videos"])
|
|
154
|
+
|
|
155
|
+
if "enhance_prompt" in step.params:
|
|
156
|
+
config_kwargs["enhance_prompt"] = bool(step.params["enhance_prompt"])
|
|
157
|
+
|
|
158
|
+
if step.seed is not None:
|
|
159
|
+
config_kwargs["seed"] = step.seed
|
|
160
|
+
|
|
161
|
+
return types.GenerateVideosConfig(**config_kwargs) if config_kwargs else None
|
|
162
|
+
|
|
163
|
+
def submit(self, step: Step, config: RunnableConfig | None = None) -> Any:
|
|
164
|
+
"""Start a video generation operation."""
|
|
165
|
+
client = self._get_client()
|
|
166
|
+
try:
|
|
167
|
+
gen_config = self._build_config(step)
|
|
168
|
+
kwargs: dict = {
|
|
169
|
+
"model": step.model,
|
|
170
|
+
"prompt": step.prompt or "",
|
|
171
|
+
}
|
|
172
|
+
if gen_config is not None:
|
|
173
|
+
kwargs["config"] = gen_config
|
|
174
|
+
|
|
175
|
+
operation = client.models.generate_videos(**kwargs)
|
|
176
|
+
# Return the provider-native operation name for resume() compatibility
|
|
177
|
+
return operation.name
|
|
178
|
+
except ProviderError:
|
|
179
|
+
raise
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
raise ProviderError(
|
|
182
|
+
f"Veo submit failed: {exc}",
|
|
183
|
+
error_code=map_google_error(exc),
|
|
184
|
+
) from exc
|
|
185
|
+
|
|
186
|
+
def poll(self, prediction_id: Any, config: RunnableConfig | None = None) -> bool:
|
|
187
|
+
"""Check if the video generation operation is done."""
|
|
188
|
+
client = self._get_client()
|
|
189
|
+
try:
|
|
190
|
+
operation = client.operations.get(prediction_id)
|
|
191
|
+
if operation.done:
|
|
192
|
+
self._cache_poll_result(prediction_id, operation)
|
|
193
|
+
return True
|
|
194
|
+
return False
|
|
195
|
+
except ProviderError:
|
|
196
|
+
raise
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
raise ProviderError(
|
|
199
|
+
f"Veo poll failed: {exc}",
|
|
200
|
+
error_code=map_google_error(exc),
|
|
201
|
+
) from exc
|
|
202
|
+
|
|
203
|
+
def fetch_output(self, prediction_id: Any, step: Step) -> Step:
|
|
204
|
+
"""Download generated video(s) and attach asset URLs."""
|
|
205
|
+
client = self._get_client()
|
|
206
|
+
try:
|
|
207
|
+
# Use cached poll result if available, otherwise fetch fresh
|
|
208
|
+
operation = self._get_cached_poll_result(prediction_id)
|
|
209
|
+
if operation is None:
|
|
210
|
+
operation = client.operations.get(prediction_id)
|
|
211
|
+
|
|
212
|
+
# Store provider metadata
|
|
213
|
+
step.provider_payload = {
|
|
214
|
+
"google": {
|
|
215
|
+
"operation_name": getattr(operation, "name", None),
|
|
216
|
+
"model": step.model,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# Check for errors in the operation result
|
|
221
|
+
if hasattr(operation, "error") and operation.error:
|
|
222
|
+
raise ProviderError(
|
|
223
|
+
str(operation.error),
|
|
224
|
+
error_code=ProviderErrorCode.UNKNOWN,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
response = operation.response
|
|
228
|
+
if response is None or not hasattr(response, "generated_videos"):
|
|
229
|
+
raise ProviderError("No video generated in response")
|
|
230
|
+
|
|
231
|
+
# Veo 3.0 models generate audio alongside video
|
|
232
|
+
is_veo3 = step.model.startswith("veo-3")
|
|
233
|
+
|
|
234
|
+
for gv in response.generated_videos:
|
|
235
|
+
video = gv.video
|
|
236
|
+
# Download to get the file URI
|
|
237
|
+
client.files.download(file=video)
|
|
238
|
+
# Use the video's URI as the asset URL
|
|
239
|
+
video_uri = getattr(video, "uri", None)
|
|
240
|
+
if video_uri:
|
|
241
|
+
validate_asset_url(video_uri)
|
|
242
|
+
vm_kwargs: dict[str, Any] = {"has_audio": is_veo3}
|
|
243
|
+
if "resolution" in step.params:
|
|
244
|
+
vm_kwargs["resolution"] = step.params["resolution"]
|
|
245
|
+
asset = Asset(url=video_uri, media_type="video/mp4")
|
|
246
|
+
asset.video = VideoMetadata(**vm_kwargs)
|
|
247
|
+
# Multi-track metadata for Veo 3 (video + generated audio)
|
|
248
|
+
if is_veo3:
|
|
249
|
+
asset.tracks = [
|
|
250
|
+
Track(kind="video", codec="h264"),
|
|
251
|
+
Track(kind="audio", codec="aac", label="generated-audio"),
|
|
252
|
+
]
|
|
253
|
+
asset.audio = AudioMetadata(codec="aac")
|
|
254
|
+
step.assets.append(asset)
|
|
255
|
+
else:
|
|
256
|
+
# Fallback: save locally and use file path
|
|
257
|
+
raise ProviderError(
|
|
258
|
+
"Veo response missing video URI — "
|
|
259
|
+
"use client.files.download() to save locally"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Track cost — accept both native and standard duration key
|
|
263
|
+
price_per_sec = _VEO_PRICING.get(step.model)
|
|
264
|
+
if price_per_sec is not None:
|
|
265
|
+
raw_dur = step.params.get("duration_seconds") or step.params.get("duration")
|
|
266
|
+
duration = int(raw_dur) if raw_dur is not None else 4
|
|
267
|
+
step.cost_usd = price_per_sec * duration * len(step.assets)
|
|
268
|
+
|
|
269
|
+
return step
|
|
270
|
+
except ProviderError:
|
|
271
|
+
raise
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
raise ProviderError(
|
|
274
|
+
f"Veo fetch_output failed: {exc}",
|
|
275
|
+
error_code=map_google_error(exc),
|
|
276
|
+
) from exc
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "genblaze-google"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Google provider adapters for genblaze (Veo video, Imagen image)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"genblaze-core>=0.1.0,<0.2",
|
|
19
|
+
"google-genai>=1.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Documentation = "https://github.com/backblaze-labs/genblaze"
|
|
24
|
+
Repository = "https://github.com/backblaze-labs/genblaze"
|
|
25
|
+
Issues = "https://github.com/backblaze-labs/genblaze/issues"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=7.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.entry-points."genblaze.providers"]
|
|
33
|
+
google-veo = "genblaze_google:VeoProvider"
|
|
34
|
+
google-imagen = "genblaze_google:ImagenProvider"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["genblaze_google"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Tests for ImagenProvider (mocked — no real API calls)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tempfile
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from genblaze_core.exceptions import ProviderError
|
|
10
|
+
from genblaze_core.models.enums import StepStatus
|
|
11
|
+
from genblaze_core.models.step import Step
|
|
12
|
+
from genblaze_core.testing import ProviderComplianceTests
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def mock_imagen(tmp_path):
|
|
17
|
+
"""Patch google.genai with a mock client."""
|
|
18
|
+
mock_image = MagicMock()
|
|
19
|
+
mock_image.save = MagicMock()
|
|
20
|
+
|
|
21
|
+
mock_response = MagicMock()
|
|
22
|
+
mock_response.generated_images = [MagicMock(image=mock_image)]
|
|
23
|
+
|
|
24
|
+
mock_client = MagicMock()
|
|
25
|
+
mock_client.models.generate_images.return_value = mock_response
|
|
26
|
+
|
|
27
|
+
# Mock the google.genai.types module
|
|
28
|
+
mock_types = MagicMock()
|
|
29
|
+
mock_genai = MagicMock()
|
|
30
|
+
mock_genai.types = mock_types
|
|
31
|
+
mock_google = MagicMock()
|
|
32
|
+
mock_google.genai = mock_genai
|
|
33
|
+
|
|
34
|
+
with patch.dict(
|
|
35
|
+
"sys.modules",
|
|
36
|
+
{"google": mock_google, "google.genai": mock_genai, "google.genai.types": mock_types},
|
|
37
|
+
):
|
|
38
|
+
from genblaze_google.imagen import ImagenProvider
|
|
39
|
+
|
|
40
|
+
provider = ImagenProvider(api_key="test-key", output_dir=str(tmp_path))
|
|
41
|
+
provider._client = mock_client
|
|
42
|
+
yield provider, mock_client
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_generate_returns_image_asset(mock_imagen):
|
|
46
|
+
provider, _ = mock_imagen
|
|
47
|
+
step = Step(
|
|
48
|
+
provider="google-imagen",
|
|
49
|
+
model="imagen-3.0-generate-002",
|
|
50
|
+
prompt="a mountain landscape",
|
|
51
|
+
)
|
|
52
|
+
result = provider.generate(step)
|
|
53
|
+
assert len(result.assets) == 1
|
|
54
|
+
assert result.assets[0].media_type == "image/png"
|
|
55
|
+
assert result.assets[0].url.startswith("file://")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_invoke_full_lifecycle(mock_imagen):
|
|
59
|
+
provider, _ = mock_imagen
|
|
60
|
+
step = Step(
|
|
61
|
+
provider="google-imagen",
|
|
62
|
+
model="imagen-3.0-generate-002",
|
|
63
|
+
prompt="a sunset",
|
|
64
|
+
)
|
|
65
|
+
result = provider.invoke(step)
|
|
66
|
+
assert result.status == StepStatus.SUCCEEDED
|
|
67
|
+
assert len(result.assets) == 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_invalid_aspect_ratio_raises(mock_imagen):
|
|
71
|
+
provider, _ = mock_imagen
|
|
72
|
+
step = Step(
|
|
73
|
+
provider="google-imagen",
|
|
74
|
+
model="imagen-3.0-generate-002",
|
|
75
|
+
prompt="test",
|
|
76
|
+
params={"aspect_ratio": "5:3"},
|
|
77
|
+
)
|
|
78
|
+
with pytest.raises(ProviderError, match="Invalid aspect_ratio"):
|
|
79
|
+
provider.generate(step)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_multiple_images(mock_imagen):
|
|
83
|
+
provider, client = mock_imagen
|
|
84
|
+
img1, img2 = MagicMock(), MagicMock()
|
|
85
|
+
img1.image = MagicMock()
|
|
86
|
+
img2.image = MagicMock()
|
|
87
|
+
client.models.generate_images.return_value = MagicMock(generated_images=[img1, img2])
|
|
88
|
+
step = Step(
|
|
89
|
+
provider="google-imagen",
|
|
90
|
+
model="imagen-3.0-generate-002",
|
|
91
|
+
prompt="cats",
|
|
92
|
+
params={"number_of_images": 2},
|
|
93
|
+
)
|
|
94
|
+
result = provider.generate(step)
|
|
95
|
+
assert len(result.assets) == 2
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_params_passed_to_api(mock_imagen):
|
|
99
|
+
provider, client = mock_imagen
|
|
100
|
+
step = Step(
|
|
101
|
+
provider="google-imagen",
|
|
102
|
+
model="imagen-3.0-generate-002",
|
|
103
|
+
prompt="test",
|
|
104
|
+
params={"aspect_ratio": "16:9", "number_of_images": 2},
|
|
105
|
+
)
|
|
106
|
+
provider.generate(step)
|
|
107
|
+
client.models.generate_images.assert_called_once()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_empty_images_raises_on_safety_filter(mock_imagen):
|
|
111
|
+
"""Imagen safety filter returns 0 images — must raise, not return empty assets."""
|
|
112
|
+
provider, client = mock_imagen
|
|
113
|
+
client.models.generate_images.return_value = MagicMock(generated_images=[])
|
|
114
|
+
step = Step(
|
|
115
|
+
provider="google-imagen",
|
|
116
|
+
model="imagen-3.0-generate-002",
|
|
117
|
+
prompt="blocked content",
|
|
118
|
+
)
|
|
119
|
+
with pytest.raises(ProviderError, match="no images"):
|
|
120
|
+
provider.generate(step)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_api_error_raises_provider_error(mock_imagen):
|
|
124
|
+
provider, client = mock_imagen
|
|
125
|
+
client.models.generate_images.side_effect = RuntimeError("403 permission denied")
|
|
126
|
+
step = Step(
|
|
127
|
+
provider="google-imagen",
|
|
128
|
+
model="imagen-3.0-generate-002",
|
|
129
|
+
prompt="test",
|
|
130
|
+
)
|
|
131
|
+
with pytest.raises(ProviderError, match="Imagen generation failed"):
|
|
132
|
+
provider.generate(step)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- Compliance harness ---
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TestImagenCompliance(ProviderComplianceTests):
|
|
139
|
+
"""Verify ImagenProvider satisfies the genblaze provider contract."""
|
|
140
|
+
|
|
141
|
+
@pytest.fixture(autouse=True)
|
|
142
|
+
def _patch_sdk(self):
|
|
143
|
+
mock_types = MagicMock()
|
|
144
|
+
mock_genai = MagicMock()
|
|
145
|
+
mock_google_mod = MagicMock()
|
|
146
|
+
mock_google_mod.genai = mock_genai
|
|
147
|
+
with patch.dict(
|
|
148
|
+
"sys.modules",
|
|
149
|
+
{
|
|
150
|
+
"google": mock_google_mod,
|
|
151
|
+
"google.genai": mock_genai,
|
|
152
|
+
"google.genai.types": mock_types,
|
|
153
|
+
},
|
|
154
|
+
):
|
|
155
|
+
yield
|
|
156
|
+
|
|
157
|
+
def make_provider(self):
|
|
158
|
+
from genblaze_google.imagen import ImagenProvider
|
|
159
|
+
|
|
160
|
+
mock_image = MagicMock()
|
|
161
|
+
mock_image.save = MagicMock()
|
|
162
|
+
mock_response = MagicMock()
|
|
163
|
+
mock_response.generated_images = [MagicMock(image=mock_image)]
|
|
164
|
+
mock_client = MagicMock()
|
|
165
|
+
mock_client.models.generate_images.return_value = mock_response
|
|
166
|
+
provider = ImagenProvider(api_key="test-key", output_dir=tempfile.mkdtemp())
|
|
167
|
+
provider._client = mock_client
|
|
168
|
+
return provider
|
|
169
|
+
|
|
170
|
+
def make_step(self):
|
|
171
|
+
return Step(
|
|
172
|
+
provider="google-imagen",
|
|
173
|
+
model="imagen-3.0-generate-002",
|
|
174
|
+
prompt="test prompt",
|
|
175
|
+
)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Tests for VeoProvider (mocked — no real API calls)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from genblaze_core.exceptions import ProviderError
|
|
10
|
+
from genblaze_core.models.enums import StepStatus
|
|
11
|
+
from genblaze_core.models.step import Step
|
|
12
|
+
from genblaze_core.testing import ProviderComplianceTests
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _make_completed_operation():
|
|
16
|
+
"""Create a mock completed operation with a generated video."""
|
|
17
|
+
video = SimpleNamespace(uri="https://storage.googleapis.com/video/out.mp4")
|
|
18
|
+
gv = SimpleNamespace(video=video)
|
|
19
|
+
response = SimpleNamespace(generated_videos=[gv])
|
|
20
|
+
return SimpleNamespace(done=True, name="op-123", error=None, response=response)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _make_pending_operation():
|
|
24
|
+
return SimpleNamespace(done=False, name="op-123", error=None, response=None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def mock_google():
|
|
29
|
+
"""Patch google.genai with a mock client."""
|
|
30
|
+
mock_types = MagicMock()
|
|
31
|
+
mock_types.GenerateVideosConfig = MagicMock
|
|
32
|
+
|
|
33
|
+
mock_genai = MagicMock()
|
|
34
|
+
mock_google_mod = MagicMock()
|
|
35
|
+
mock_google_mod.genai = mock_genai
|
|
36
|
+
|
|
37
|
+
mock_client = MagicMock()
|
|
38
|
+
mock_client.models.generate_videos.return_value = _make_pending_operation()
|
|
39
|
+
mock_client.operations.get.return_value = _make_completed_operation()
|
|
40
|
+
mock_client.files.download.return_value = None
|
|
41
|
+
|
|
42
|
+
with patch.dict(
|
|
43
|
+
"sys.modules",
|
|
44
|
+
{
|
|
45
|
+
"google": mock_google_mod,
|
|
46
|
+
"google.genai": mock_genai,
|
|
47
|
+
"google.genai.types": mock_types,
|
|
48
|
+
},
|
|
49
|
+
):
|
|
50
|
+
from genblaze_google import VeoProvider
|
|
51
|
+
|
|
52
|
+
provider = VeoProvider(api_key="test-key")
|
|
53
|
+
provider._client = mock_client
|
|
54
|
+
yield provider, mock_client
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_submit_returns_operation_name(mock_google):
|
|
58
|
+
provider, client = mock_google
|
|
59
|
+
step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="a sunset")
|
|
60
|
+
pred_id = provider.submit(step)
|
|
61
|
+
# Returns the provider-native operation name, not step_id
|
|
62
|
+
assert pred_id == "op-123"
|
|
63
|
+
client.models.generate_videos.assert_called_once()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_poll_returns_false_when_pending(mock_google):
|
|
67
|
+
provider, client = mock_google
|
|
68
|
+
step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="a sunset")
|
|
69
|
+
pred_id = provider.submit(step)
|
|
70
|
+
client.operations.get.return_value = _make_pending_operation()
|
|
71
|
+
assert provider.poll(pred_id) is False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_poll_returns_true_when_done(mock_google):
|
|
75
|
+
provider, client = mock_google
|
|
76
|
+
step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="a sunset")
|
|
77
|
+
pred_id = provider.submit(step)
|
|
78
|
+
client.operations.get.return_value = _make_completed_operation()
|
|
79
|
+
assert provider.poll(pred_id) is True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_fetch_output_attaches_asset(mock_google):
|
|
83
|
+
provider, client = mock_google
|
|
84
|
+
step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="a sunset")
|
|
85
|
+
pred_id = provider.submit(step)
|
|
86
|
+
# Poll to cache the completed operation
|
|
87
|
+
client.operations.get.return_value = _make_completed_operation()
|
|
88
|
+
provider.poll(pred_id)
|
|
89
|
+
|
|
90
|
+
result = provider.fetch_output(pred_id, step)
|
|
91
|
+
assert len(result.assets) == 1
|
|
92
|
+
assert result.assets[0].media_type == "video/mp4"
|
|
93
|
+
assert "storage.googleapis.com" in result.assets[0].url
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_fetch_output_error_raises(mock_google):
|
|
97
|
+
provider, client = mock_google
|
|
98
|
+
step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="bad")
|
|
99
|
+
pred_id = provider.submit(step)
|
|
100
|
+
error_op = SimpleNamespace(
|
|
101
|
+
done=True, name="op-err", error="Safety filter triggered", response=None
|
|
102
|
+
)
|
|
103
|
+
client.operations.get.return_value = error_op
|
|
104
|
+
provider.poll(pred_id)
|
|
105
|
+
|
|
106
|
+
with pytest.raises(ProviderError, match="Safety filter"):
|
|
107
|
+
provider.fetch_output(pred_id, step)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_invoke_full_lifecycle(mock_google):
|
|
111
|
+
"""Full invoke() succeeds with mocked client."""
|
|
112
|
+
provider, client = mock_google
|
|
113
|
+
client.operations.get.return_value = _make_completed_operation()
|
|
114
|
+
|
|
115
|
+
step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="a sunset")
|
|
116
|
+
result = provider.invoke(step)
|
|
117
|
+
assert result.status == StepStatus.SUCCEEDED
|
|
118
|
+
assert len(result.assets) == 1
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_invalid_aspect_ratio_raises(mock_google):
|
|
122
|
+
provider, _ = mock_google
|
|
123
|
+
step = Step(
|
|
124
|
+
provider="google-veo",
|
|
125
|
+
model="veo-2.0-generate-001",
|
|
126
|
+
prompt="test",
|
|
127
|
+
params={"aspect_ratio": "4:3"},
|
|
128
|
+
)
|
|
129
|
+
with pytest.raises(ProviderError, match="Invalid aspect_ratio"):
|
|
130
|
+
provider.submit(step)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_invalid_resolution_raises(mock_google):
|
|
134
|
+
provider, _ = mock_google
|
|
135
|
+
step = Step(
|
|
136
|
+
provider="google-veo",
|
|
137
|
+
model="veo-2.0-generate-001",
|
|
138
|
+
prompt="test",
|
|
139
|
+
params={"resolution": "360p"},
|
|
140
|
+
)
|
|
141
|
+
with pytest.raises(ProviderError, match="Invalid resolution"):
|
|
142
|
+
provider.submit(step)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_resume_works_with_operation_name(mock_google):
|
|
146
|
+
"""resume() works with just the operation name (no in-memory state needed)."""
|
|
147
|
+
provider, client = mock_google
|
|
148
|
+
client.operations.get.return_value = _make_completed_operation()
|
|
149
|
+
step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="a sunset")
|
|
150
|
+
# resume with just the operation name — no prior submit() needed
|
|
151
|
+
result = provider.resume("op-123", step)
|
|
152
|
+
assert result.status == StepStatus.SUCCEEDED
|
|
153
|
+
assert len(result.assets) == 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_duration_alias(mock_google):
|
|
157
|
+
"""Standard 'duration' param is aliased to 'duration_seconds' for Veo."""
|
|
158
|
+
provider, client = mock_google
|
|
159
|
+
step = Step(
|
|
160
|
+
provider="google-veo",
|
|
161
|
+
model="veo-2.0-generate-001",
|
|
162
|
+
prompt="test",
|
|
163
|
+
params={"duration": "6"},
|
|
164
|
+
)
|
|
165
|
+
provider.submit(step)
|
|
166
|
+
client.models.generate_videos.assert_called_once()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_cost_tracked(mock_google):
|
|
170
|
+
"""Cost is set based on model and duration."""
|
|
171
|
+
provider, client = mock_google
|
|
172
|
+
step = Step(
|
|
173
|
+
provider="google-veo",
|
|
174
|
+
model="veo-2.0-generate-001",
|
|
175
|
+
prompt="a sunset",
|
|
176
|
+
params={"duration_seconds": "6"},
|
|
177
|
+
)
|
|
178
|
+
pred_id = provider.submit(step)
|
|
179
|
+
client.operations.get.return_value = _make_completed_operation()
|
|
180
|
+
provider.poll(pred_id)
|
|
181
|
+
result = provider.fetch_output(pred_id, step)
|
|
182
|
+
assert result.cost_usd == pytest.approx(0.35 * 6)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_cost_tracked_duration_alias(mock_google):
|
|
186
|
+
"""Cost uses the standard 'duration' alias."""
|
|
187
|
+
provider, client = mock_google
|
|
188
|
+
step = Step(
|
|
189
|
+
provider="google-veo",
|
|
190
|
+
model="veo-3.0-generate-001",
|
|
191
|
+
prompt="a sunset",
|
|
192
|
+
params={"duration": "8"},
|
|
193
|
+
)
|
|
194
|
+
pred_id = provider.submit(step)
|
|
195
|
+
client.operations.get.return_value = _make_completed_operation()
|
|
196
|
+
provider.poll(pred_id)
|
|
197
|
+
result = provider.fetch_output(pred_id, step)
|
|
198
|
+
assert result.cost_usd == pytest.approx(0.50 * 8)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_cost_default_duration(mock_google):
|
|
202
|
+
"""Cost uses default 4s when no duration specified."""
|
|
203
|
+
provider, client = mock_google
|
|
204
|
+
step = Step(
|
|
205
|
+
provider="google-veo",
|
|
206
|
+
model="veo-2.0-generate-001",
|
|
207
|
+
prompt="a sunset",
|
|
208
|
+
)
|
|
209
|
+
pred_id = provider.submit(step)
|
|
210
|
+
client.operations.get.return_value = _make_completed_operation()
|
|
211
|
+
provider.poll(pred_id)
|
|
212
|
+
result = provider.fetch_output(pred_id, step)
|
|
213
|
+
assert result.cost_usd == pytest.approx(0.35 * 4)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_cost_none_unknown_model(mock_google):
|
|
217
|
+
"""Cost stays None for unknown model."""
|
|
218
|
+
provider, client = mock_google
|
|
219
|
+
step = Step(
|
|
220
|
+
provider="google-veo",
|
|
221
|
+
model="unknown-veo-model",
|
|
222
|
+
prompt="a sunset",
|
|
223
|
+
)
|
|
224
|
+
pred_id = provider.submit(step)
|
|
225
|
+
client.operations.get.return_value = _make_completed_operation()
|
|
226
|
+
provider.poll(pred_id)
|
|
227
|
+
result = provider.fetch_output(pred_id, step)
|
|
228
|
+
assert result.cost_usd is None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_veo3_populates_tracks(mock_google):
|
|
232
|
+
"""Veo 3 models populate multi-track metadata on assets."""
|
|
233
|
+
provider, client = mock_google
|
|
234
|
+
step = Step(
|
|
235
|
+
provider="google-veo",
|
|
236
|
+
model="veo-3.0-generate-001",
|
|
237
|
+
prompt="a sunset with music",
|
|
238
|
+
)
|
|
239
|
+
pred_id = provider.submit(step)
|
|
240
|
+
client.operations.get.return_value = _make_completed_operation()
|
|
241
|
+
provider.poll(pred_id)
|
|
242
|
+
result = provider.fetch_output(pred_id, step)
|
|
243
|
+
assert len(result.assets) == 1
|
|
244
|
+
asset = result.assets[0]
|
|
245
|
+
assert asset.tracks is not None
|
|
246
|
+
assert len(asset.tracks) == 2
|
|
247
|
+
assert asset.tracks[0].kind == "video"
|
|
248
|
+
assert asset.tracks[0].codec == "h264"
|
|
249
|
+
assert asset.tracks[1].kind == "audio"
|
|
250
|
+
assert asset.tracks[1].codec == "aac"
|
|
251
|
+
assert asset.tracks[1].label == "generated-audio"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_veo3_fast_populates_tracks(mock_google):
|
|
255
|
+
"""Veo 3 fast model also populates tracks."""
|
|
256
|
+
provider, client = mock_google
|
|
257
|
+
step = Step(
|
|
258
|
+
provider="google-veo",
|
|
259
|
+
model="veo-3.0-fast-generate-001",
|
|
260
|
+
prompt="quick clip",
|
|
261
|
+
)
|
|
262
|
+
pred_id = provider.submit(step)
|
|
263
|
+
client.operations.get.return_value = _make_completed_operation()
|
|
264
|
+
provider.poll(pred_id)
|
|
265
|
+
result = provider.fetch_output(pred_id, step)
|
|
266
|
+
assert result.assets[0].tracks is not None
|
|
267
|
+
assert len(result.assets[0].tracks) == 2
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_veo2_no_tracks(mock_google):
|
|
271
|
+
"""Veo 2 models do not populate tracks."""
|
|
272
|
+
provider, client = mock_google
|
|
273
|
+
step = Step(
|
|
274
|
+
provider="google-veo",
|
|
275
|
+
model="veo-2.0-generate-001",
|
|
276
|
+
prompt="a sunset",
|
|
277
|
+
)
|
|
278
|
+
pred_id = provider.submit(step)
|
|
279
|
+
client.operations.get.return_value = _make_completed_operation()
|
|
280
|
+
provider.poll(pred_id)
|
|
281
|
+
result = provider.fetch_output(pred_id, step)
|
|
282
|
+
assert result.assets[0].tracks is None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# --- Compliance harness ---
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class TestVeoCompliance(ProviderComplianceTests):
|
|
289
|
+
"""Verify VeoProvider satisfies the genblaze provider contract."""
|
|
290
|
+
|
|
291
|
+
@pytest.fixture(autouse=True)
|
|
292
|
+
def _patch_sdk(self):
|
|
293
|
+
mock_types = MagicMock()
|
|
294
|
+
mock_types.GenerateVideosConfig = MagicMock
|
|
295
|
+
mock_genai = MagicMock()
|
|
296
|
+
mock_google_mod = MagicMock()
|
|
297
|
+
mock_google_mod.genai = mock_genai
|
|
298
|
+
with patch.dict(
|
|
299
|
+
"sys.modules",
|
|
300
|
+
{
|
|
301
|
+
"google": mock_google_mod,
|
|
302
|
+
"google.genai": mock_genai,
|
|
303
|
+
"google.genai.types": mock_types,
|
|
304
|
+
},
|
|
305
|
+
):
|
|
306
|
+
yield
|
|
307
|
+
|
|
308
|
+
def make_provider(self):
|
|
309
|
+
from genblaze_google import VeoProvider
|
|
310
|
+
|
|
311
|
+
mock_client = MagicMock()
|
|
312
|
+
mock_client.models.generate_videos.return_value = _make_pending_operation()
|
|
313
|
+
mock_client.operations.get.return_value = _make_completed_operation()
|
|
314
|
+
mock_client.files.download.return_value = None
|
|
315
|
+
provider = VeoProvider(api_key="test-key")
|
|
316
|
+
provider._client = mock_client
|
|
317
|
+
return provider
|
|
318
|
+
|
|
319
|
+
def make_step(self):
|
|
320
|
+
return Step(
|
|
321
|
+
provider="google-veo",
|
|
322
|
+
model="veo-2.0-generate-001",
|
|
323
|
+
prompt="test prompt",
|
|
324
|
+
)
|