@weirdfingers/baseboards 0.2.1 → 0.4.0
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.
- package/README.md +14 -4
- package/dist/index.js +13 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/api/ARTIFACT_RESOLUTION_GUIDE.md +148 -0
- package/templates/api/Dockerfile +2 -2
- package/templates/api/README.md +138 -6
- package/templates/api/config/generators.yaml +41 -7
- package/templates/api/docs/TESTING_LIVE_APIS.md +417 -0
- package/templates/api/pyproject.toml +49 -9
- package/templates/api/src/boards/__init__.py +1 -1
- package/templates/api/src/boards/auth/adapters/__init__.py +9 -2
- package/templates/api/src/boards/auth/factory.py +16 -2
- package/templates/api/src/boards/generators/__init__.py +2 -2
- package/templates/api/src/boards/generators/artifact_resolution.py +372 -0
- package/templates/api/src/boards/generators/artifacts.py +4 -4
- package/templates/api/src/boards/generators/base.py +8 -4
- package/templates/api/src/boards/generators/implementations/__init__.py +4 -2
- package/templates/api/src/boards/generators/implementations/fal/__init__.py +25 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +4 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/minimax_music_v2.py +173 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/minimax_speech_2_6_turbo.py +221 -0
- package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +17 -0
- package/templates/api/src/boards/generators/implementations/fal/image/flux_pro_kontext.py +216 -0
- package/templates/api/src/boards/generators/implementations/fal/image/flux_pro_ultra.py +197 -0
- package/templates/api/src/boards/generators/implementations/fal/image/imagen4_preview.py +191 -0
- package/templates/api/src/boards/generators/implementations/fal/image/imagen4_preview_fast.py +179 -0
- package/templates/api/src/boards/generators/implementations/fal/image/nano_banana.py +183 -0
- package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_edit.py +212 -0
- package/templates/api/src/boards/generators/implementations/fal/utils.py +61 -0
- package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +13 -0
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_v2_5_turbo_pro_text_to_video.py +168 -0
- package/templates/api/src/boards/generators/implementations/fal/video/sync_lipsync_v2.py +167 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +180 -0
- package/templates/api/src/boards/generators/implementations/openai/__init__.py +1 -0
- package/templates/api/src/boards/generators/implementations/openai/audio/__init__.py +1 -0
- package/templates/api/src/boards/generators/implementations/{audio → openai/audio}/whisper.py +9 -6
- package/templates/api/src/boards/generators/implementations/openai/image/__init__.py +1 -0
- package/templates/api/src/boards/generators/implementations/{image → openai/image}/dalle3.py +8 -5
- package/templates/api/src/boards/generators/implementations/replicate/__init__.py +1 -0
- package/templates/api/src/boards/generators/implementations/replicate/image/__init__.py +1 -0
- package/templates/api/src/boards/generators/implementations/{image → replicate/image}/flux_pro.py +8 -5
- package/templates/api/src/boards/generators/implementations/replicate/video/__init__.py +1 -0
- package/templates/api/src/boards/generators/implementations/{video → replicate/video}/lipsync.py +9 -6
- package/templates/api/src/boards/generators/resolution.py +80 -20
- package/templates/api/src/boards/jobs/repository.py +49 -0
- package/templates/api/src/boards/storage/factory.py +16 -6
- package/templates/api/src/boards/workers/actors.py +69 -5
- package/templates/api/src/boards/workers/context.py +177 -21
- package/templates/web/package.json +2 -1
- package/templates/web/src/components/boards/GenerationInput.tsx +154 -52
- package/templates/web/src/components/boards/GeneratorSelector.tsx +57 -59
- package/templates/web/src/components/ui/dropdown-menu.tsx +200 -0
- package/templates/api/src/boards/generators/implementations/audio/__init__.py +0 -3
- package/templates/api/src/boards/generators/implementations/image/__init__.py +0 -3
- package/templates/api/src/boards/generators/implementations/video/__init__.py +0 -3
|
@@ -36,7 +36,6 @@ classifiers = [
|
|
|
36
36
|
]
|
|
37
37
|
dependencies = [
|
|
38
38
|
"sqlalchemy>=2.0.0",
|
|
39
|
-
"supabase>=2.0.0",
|
|
40
39
|
"pydantic>=2.0.0",
|
|
41
40
|
"pydantic-settings>=2.0.0",
|
|
42
41
|
"httpx>=0.24.0",
|
|
@@ -60,6 +59,52 @@ dependencies = [
|
|
|
60
59
|
]
|
|
61
60
|
|
|
62
61
|
[project.optional-dependencies]
|
|
62
|
+
# Generator providers (per-provider)
|
|
63
|
+
generators-replicate = ["replicate>=1.0.4"]
|
|
64
|
+
generators-openai = ["openai>=1.60.1"]
|
|
65
|
+
generators-fal = ["fal-client>=0.5.0"]
|
|
66
|
+
generators-anthropic = ["anthropic>=0.25.0"]
|
|
67
|
+
generators-together = ["together>=1.0.0"]
|
|
68
|
+
|
|
69
|
+
# Convenience: all generators
|
|
70
|
+
generators-all = [
|
|
71
|
+
"replicate>=1.0.4",
|
|
72
|
+
"openai>=1.60.1",
|
|
73
|
+
"fal-client>=0.5.0",
|
|
74
|
+
"anthropic>=0.25.0",
|
|
75
|
+
"together>=1.0.0",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
# Storage providers (per-provider)
|
|
79
|
+
storage-supabase = ["supabase>=2.0.0"]
|
|
80
|
+
storage-s3 = ["boto3>=1.34.0", "aioboto3>=12.0.0"]
|
|
81
|
+
storage-gcs = ["google-cloud-storage>=2.10.0"]
|
|
82
|
+
|
|
83
|
+
# Convenience: all storage backends
|
|
84
|
+
storage-all = [
|
|
85
|
+
"supabase>=2.0.0",
|
|
86
|
+
"boto3>=1.34.0",
|
|
87
|
+
"aioboto3>=12.0.0",
|
|
88
|
+
"google-cloud-storage>=2.10.0",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
# Auth providers (only Supabase needs extra deps beyond core httpx/pyjwt)
|
|
92
|
+
auth-supabase = ["supabase>=2.0.0"]
|
|
93
|
+
|
|
94
|
+
# Convenience: everything
|
|
95
|
+
all = [
|
|
96
|
+
"replicate>=1.0.4",
|
|
97
|
+
"openai>=1.60.1",
|
|
98
|
+
"fal-client>=0.5.0",
|
|
99
|
+
"anthropic>=0.25.0",
|
|
100
|
+
"together>=1.0.0",
|
|
101
|
+
"supabase>=2.0.0",
|
|
102
|
+
"boto3>=1.34.0",
|
|
103
|
+
"aioboto3>=12.0.0",
|
|
104
|
+
"google-cloud-storage>=2.10.0",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# Development dependencies
|
|
63
108
|
dev = [
|
|
64
109
|
"pytest>=7.0.0",
|
|
65
110
|
"pytest-asyncio>=0.21.0",
|
|
@@ -74,21 +119,15 @@ dev = [
|
|
|
74
119
|
"anthropic>=0.25.0",
|
|
75
120
|
"replicate>=1.0.4",
|
|
76
121
|
"together>=1.0.0",
|
|
122
|
+
"fal-client>=0.5.0",
|
|
77
123
|
# Include all storage deps for typecheck
|
|
124
|
+
"supabase>=2.0.0",
|
|
78
125
|
"boto3>=1.34.0",
|
|
79
126
|
"aioboto3>=12.0.0",
|
|
80
127
|
"google-cloud-storage>=2.10.0",
|
|
81
128
|
# Type stubs
|
|
82
129
|
"types-redis>=4.6.0",
|
|
83
130
|
]
|
|
84
|
-
providers = [
|
|
85
|
-
"openai>=1.60.1",
|
|
86
|
-
"anthropic>=0.25.0",
|
|
87
|
-
"replicate>=1.0.4",
|
|
88
|
-
"together>=1.0.0",
|
|
89
|
-
]
|
|
90
|
-
storage-s3 = ["boto3>=1.34.0", "aioboto3>=12.0.0"]
|
|
91
|
-
storage-gcs = ["google-cloud-storage>=2.10.0"]
|
|
92
131
|
|
|
93
132
|
[project.urls]
|
|
94
133
|
Homepage = "https://github.com/weirdfingers/boards"
|
|
@@ -156,6 +195,7 @@ dev-dependencies = [
|
|
|
156
195
|
"anthropic>=0.25.0",
|
|
157
196
|
"replicate>=1.0.4",
|
|
158
197
|
"together>=1.0.0",
|
|
198
|
+
"fal-client>=0.5.0",
|
|
159
199
|
# Include all storage deps for typecheck
|
|
160
200
|
"boto3>=1.34.0",
|
|
161
201
|
"aioboto3>=12.0.0",
|
|
@@ -6,15 +6,22 @@ from .clerk import ClerkAuthAdapter
|
|
|
6
6
|
from .jwt import JWTAuthAdapter
|
|
7
7
|
from .none import NoAuthAdapter
|
|
8
8
|
from .oidc import OIDCAdapter
|
|
9
|
-
from .supabase import SupabaseAuthAdapter
|
|
10
9
|
|
|
10
|
+
# Always available adapters
|
|
11
11
|
__all__ = [
|
|
12
12
|
"AuthAdapter",
|
|
13
13
|
"Principal",
|
|
14
|
-
"SupabaseAuthAdapter",
|
|
15
14
|
"JWTAuthAdapter",
|
|
16
15
|
"NoAuthAdapter",
|
|
17
16
|
"ClerkAuthAdapter",
|
|
18
17
|
"Auth0OIDCAdapter",
|
|
19
18
|
"OIDCAdapter",
|
|
20
19
|
]
|
|
20
|
+
|
|
21
|
+
# Optional auth providers - imported conditionally to avoid import errors
|
|
22
|
+
try:
|
|
23
|
+
from .supabase import SupabaseAuthAdapter
|
|
24
|
+
|
|
25
|
+
__all__.append("SupabaseAuthAdapter")
|
|
26
|
+
except ImportError:
|
|
27
|
+
pass
|
|
@@ -11,7 +11,15 @@ from .adapters.clerk import ClerkAuthAdapter
|
|
|
11
11
|
from .adapters.jwt import JWTAuthAdapter
|
|
12
12
|
from .adapters.none import NoAuthAdapter
|
|
13
13
|
from .adapters.oidc import OIDCAdapter
|
|
14
|
-
|
|
14
|
+
|
|
15
|
+
# Optional Supabase adapter - imported conditionally
|
|
16
|
+
try:
|
|
17
|
+
from .adapters.supabase import SupabaseAuthAdapter
|
|
18
|
+
|
|
19
|
+
SUPABASE_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
SUPABASE_AVAILABLE = False
|
|
22
|
+
SupabaseAuthAdapter = None # type: ignore
|
|
15
23
|
|
|
16
24
|
|
|
17
25
|
def get_auth_adapter() -> AuthAdapter:
|
|
@@ -46,6 +54,12 @@ def get_auth_adapter() -> AuthAdapter:
|
|
|
46
54
|
)
|
|
47
55
|
|
|
48
56
|
elif provider == "supabase":
|
|
57
|
+
if not SUPABASE_AVAILABLE:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"Supabase auth provider is not available. "
|
|
60
|
+
"Install the supabase package: pip install 'weirdfingers-boards[auth-supabase]'"
|
|
61
|
+
)
|
|
62
|
+
|
|
49
63
|
url = config.get("url") or os.getenv("SUPABASE_URL")
|
|
50
64
|
service_role_key = config.get("service_role_key") or os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
|
51
65
|
|
|
@@ -55,7 +69,7 @@ def get_auth_adapter() -> AuthAdapter:
|
|
|
55
69
|
"Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY or provide in config."
|
|
56
70
|
)
|
|
57
71
|
|
|
58
|
-
return SupabaseAuthAdapter(url=url, service_role_key=service_role_key)
|
|
72
|
+
return SupabaseAuthAdapter(url=url, service_role_key=service_role_key) # type: ignore
|
|
59
73
|
|
|
60
74
|
elif provider == "clerk":
|
|
61
75
|
secret_key = config.get("secret_key") or os.getenv("CLERK_SECRET_KEY")
|
|
@@ -12,13 +12,13 @@ Key components:
|
|
|
12
12
|
|
|
13
13
|
Example usage:
|
|
14
14
|
from boards.generators import registry
|
|
15
|
-
from boards.generators.implementations.image.flux_pro import
|
|
15
|
+
from boards.generators.implementations.replicate.image.flux_pro import ReplicateFluxProGenerator
|
|
16
16
|
|
|
17
17
|
# Get available generators
|
|
18
18
|
image_generators = registry.list_by_artifact_type("image")
|
|
19
19
|
|
|
20
20
|
# Use a specific generator
|
|
21
|
-
flux = registry.get("flux-pro")
|
|
21
|
+
flux = registry.get("replicate-flux-pro")
|
|
22
22
|
result = await flux.generate(inputs)
|
|
23
23
|
"""
|
|
24
24
|
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for resolving generation IDs to artifact objects.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for converting generation ID strings (UUIDs)
|
|
5
|
+
into typed artifact objects (ImageArtifact, AudioArtifact, etc.) with proper
|
|
6
|
+
validation of ownership and completion status.
|
|
7
|
+
|
|
8
|
+
The main approach is to automatically detect artifact fields via type introspection
|
|
9
|
+
and resolve them BEFORE Pydantic validation.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Any, TypeVar, get_args, get_origin
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
from ..dbmodels import Generations
|
|
19
|
+
from ..jobs import repository as jobs_repo
|
|
20
|
+
from ..logging import get_logger
|
|
21
|
+
from .artifacts import AudioArtifact, ImageArtifact, TextArtifact, VideoArtifact
|
|
22
|
+
|
|
23
|
+
logger = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
# Type variable for artifact types
|
|
26
|
+
TArtifact = TypeVar("TArtifact", ImageArtifact, VideoArtifact, AudioArtifact, TextArtifact)
|
|
27
|
+
|
|
28
|
+
# Set of all artifact types for quick checking
|
|
29
|
+
ARTIFACT_TYPES = {ImageArtifact, VideoArtifact, AudioArtifact, TextArtifact}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _extract_artifact_type(annotation: Any) -> type[TArtifact] | None:
|
|
33
|
+
"""Extract artifact type from a field annotation.
|
|
34
|
+
|
|
35
|
+
Handles both single artifacts (ImageArtifact) and lists (list[ImageArtifact]).
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
annotation: The type annotation from a Pydantic field
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The artifact class if this is an artifact field, None otherwise
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
_extract_artifact_type(ImageArtifact) -> ImageArtifact
|
|
45
|
+
_extract_artifact_type(list[ImageArtifact]) -> ImageArtifact
|
|
46
|
+
_extract_artifact_type(str) -> None
|
|
47
|
+
"""
|
|
48
|
+
# Direct artifact type (e.g., ImageArtifact)
|
|
49
|
+
if annotation in ARTIFACT_TYPES:
|
|
50
|
+
return annotation # type: ignore[return-value]
|
|
51
|
+
|
|
52
|
+
# List of artifacts (e.g., list[ImageArtifact])
|
|
53
|
+
origin = get_origin(annotation)
|
|
54
|
+
if origin is list:
|
|
55
|
+
args = get_args(annotation)
|
|
56
|
+
if args and args[0] in ARTIFACT_TYPES:
|
|
57
|
+
return args[0] # type: ignore[return-value]
|
|
58
|
+
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_artifact_fields(schema: type[BaseModel]) -> dict[str, tuple[type[TArtifact], bool]]:
|
|
63
|
+
"""Automatically extract artifact fields from a Pydantic schema.
|
|
64
|
+
|
|
65
|
+
Inspects the schema's field annotations and returns a mapping of field names
|
|
66
|
+
to their artifact types and whether they expect a list.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
schema: Pydantic model class to inspect
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Dictionary mapping field names to (artifact_type, is_list) tuples
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
class MyInput(BaseModel):
|
|
76
|
+
prompt: str
|
|
77
|
+
image_source: ImageArtifact
|
|
78
|
+
video_sources: list[VideoArtifact]
|
|
79
|
+
|
|
80
|
+
extract_artifact_fields(MyInput)
|
|
81
|
+
# Returns: {"image_source": (ImageArtifact, False), "video_sources": (VideoArtifact, True)}
|
|
82
|
+
"""
|
|
83
|
+
artifact_fields: dict[str, tuple[type[TArtifact], bool]] = {}
|
|
84
|
+
|
|
85
|
+
for field_name, field_info in schema.model_fields.items():
|
|
86
|
+
artifact_type = _extract_artifact_type(field_info.annotation)
|
|
87
|
+
if artifact_type is not None:
|
|
88
|
+
# Check if the field is a list type
|
|
89
|
+
origin = get_origin(field_info.annotation)
|
|
90
|
+
is_list = origin is list
|
|
91
|
+
artifact_fields[field_name] = (artifact_type, is_list)
|
|
92
|
+
|
|
93
|
+
return artifact_fields
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _get_artifact_type_name[T: (ImageArtifact, VideoArtifact, AudioArtifact, TextArtifact)](
|
|
97
|
+
artifact_class: type[T],
|
|
98
|
+
) -> str:
|
|
99
|
+
"""Get the database artifact_type string for an artifact class."""
|
|
100
|
+
type_map = {
|
|
101
|
+
ImageArtifact: "image",
|
|
102
|
+
VideoArtifact: "video",
|
|
103
|
+
AudioArtifact: "audio",
|
|
104
|
+
TextArtifact: "text",
|
|
105
|
+
}
|
|
106
|
+
artifact_type = type_map.get(artifact_class)
|
|
107
|
+
if artifact_type is None:
|
|
108
|
+
raise ValueError(f"Unsupported artifact class: {artifact_class}")
|
|
109
|
+
return artifact_type
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _generation_to_artifact[T: (ImageArtifact, VideoArtifact, AudioArtifact, TextArtifact)](
|
|
113
|
+
generation: Generations, artifact_class: type[T]
|
|
114
|
+
) -> T:
|
|
115
|
+
"""Convert a Generations database record to an artifact object.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
generation: Database generation record
|
|
119
|
+
artifact_class: Target artifact class (ImageArtifact, VideoArtifact, etc.)
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Artifact object populated from the generation record
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValueError: If generation is missing required fields or data
|
|
126
|
+
"""
|
|
127
|
+
if not generation.storage_url:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Generation {generation.id} has no storage_url - generation may not be completed"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Get output metadata
|
|
133
|
+
metadata = generation.output_metadata or {}
|
|
134
|
+
|
|
135
|
+
# Build artifact based on type
|
|
136
|
+
if artifact_class == ImageArtifact:
|
|
137
|
+
width = metadata.get("width")
|
|
138
|
+
height = metadata.get("height")
|
|
139
|
+
return ImageArtifact(
|
|
140
|
+
generation_id=str(generation.id),
|
|
141
|
+
storage_url=generation.storage_url,
|
|
142
|
+
format=metadata.get("format", "png"),
|
|
143
|
+
width=width,
|
|
144
|
+
height=height,
|
|
145
|
+
) # type: ignore[return-value]
|
|
146
|
+
|
|
147
|
+
elif artifact_class == VideoArtifact:
|
|
148
|
+
width = metadata.get("width")
|
|
149
|
+
height = metadata.get("height")
|
|
150
|
+
return VideoArtifact(
|
|
151
|
+
generation_id=str(generation.id),
|
|
152
|
+
storage_url=generation.storage_url,
|
|
153
|
+
format=metadata.get("format", "mp4"),
|
|
154
|
+
width=width,
|
|
155
|
+
height=height,
|
|
156
|
+
duration=metadata.get("duration"),
|
|
157
|
+
fps=metadata.get("fps"),
|
|
158
|
+
) # type: ignore[return-value]
|
|
159
|
+
|
|
160
|
+
elif artifact_class == AudioArtifact:
|
|
161
|
+
return AudioArtifact(
|
|
162
|
+
generation_id=str(generation.id),
|
|
163
|
+
storage_url=generation.storage_url,
|
|
164
|
+
format=metadata.get("format", "mp3"),
|
|
165
|
+
duration=metadata.get("duration"),
|
|
166
|
+
sample_rate=metadata.get("sample_rate"),
|
|
167
|
+
channels=metadata.get("channels"),
|
|
168
|
+
) # type: ignore[return-value]
|
|
169
|
+
|
|
170
|
+
elif artifact_class == TextArtifact:
|
|
171
|
+
content = metadata.get("content")
|
|
172
|
+
if content is None:
|
|
173
|
+
raise ValueError(f"Generation {generation.id} missing text content in output_metadata")
|
|
174
|
+
return TextArtifact(
|
|
175
|
+
generation_id=str(generation.id),
|
|
176
|
+
storage_url=generation.storage_url,
|
|
177
|
+
format=metadata.get("format", "plain"),
|
|
178
|
+
content=content,
|
|
179
|
+
) # type: ignore[return-value]
|
|
180
|
+
|
|
181
|
+
else:
|
|
182
|
+
raise ValueError(f"Unsupported artifact class: {artifact_class}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def resolve_generation_ids_to_artifacts[
|
|
186
|
+
T: (ImageArtifact, VideoArtifact, AudioArtifact, TextArtifact)
|
|
187
|
+
](
|
|
188
|
+
generation_ids: list[str | UUID],
|
|
189
|
+
artifact_class: type[T],
|
|
190
|
+
session: AsyncSession,
|
|
191
|
+
tenant_id: UUID,
|
|
192
|
+
) -> list[T]:
|
|
193
|
+
"""Convert a list of generation IDs to typed artifact objects.
|
|
194
|
+
|
|
195
|
+
This function:
|
|
196
|
+
1. Queries the database for each generation ID
|
|
197
|
+
2. Validates the generation is completed
|
|
198
|
+
3. Validates the artifact type matches
|
|
199
|
+
4. Validates the user has access (tenant_id matches)
|
|
200
|
+
5. Converts to the appropriate artifact object
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
generation_ids: List of generation IDs (as strings or UUIDs)
|
|
204
|
+
artifact_class: Target artifact class (ImageArtifact, VideoArtifact, etc.)
|
|
205
|
+
session: Database session for queries
|
|
206
|
+
tenant_id: Tenant ID for access validation
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
List of artifact objects
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
ValueError: If any generation is not found, not completed, wrong type, or access denied
|
|
213
|
+
"""
|
|
214
|
+
expected_artifact_type = _get_artifact_type_name(artifact_class)
|
|
215
|
+
artifacts: list[T] = []
|
|
216
|
+
|
|
217
|
+
for gen_id in generation_ids:
|
|
218
|
+
# Query generation from database
|
|
219
|
+
try:
|
|
220
|
+
generation = await jobs_repo.get_generation(session, gen_id)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
raise ValueError(f"Generation {gen_id} not found") from e
|
|
223
|
+
|
|
224
|
+
# Validate tenant access
|
|
225
|
+
if generation.tenant_id != tenant_id:
|
|
226
|
+
raise ValueError(f"Access denied to generation {gen_id} - tenant mismatch")
|
|
227
|
+
|
|
228
|
+
# Validate completion status
|
|
229
|
+
if generation.status != "completed":
|
|
230
|
+
raise ValueError(f"Generation {gen_id} is not completed (status: {generation.status})")
|
|
231
|
+
|
|
232
|
+
# Validate artifact type
|
|
233
|
+
if generation.artifact_type != expected_artifact_type:
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"Generation {gen_id} has wrong artifact type: "
|
|
236
|
+
f"expected {expected_artifact_type}, got {generation.artifact_type}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Convert to artifact object
|
|
240
|
+
try:
|
|
241
|
+
artifact = _generation_to_artifact(generation, artifact_class)
|
|
242
|
+
artifacts.append(artifact)
|
|
243
|
+
except ValueError as e:
|
|
244
|
+
raise ValueError(f"Failed to convert generation {gen_id} to artifact: {e}") from e
|
|
245
|
+
|
|
246
|
+
return artifacts
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def resolve_input_artifacts(
|
|
250
|
+
input_params: dict[str, Any],
|
|
251
|
+
schema: type[BaseModel],
|
|
252
|
+
session: AsyncSession,
|
|
253
|
+
tenant_id: UUID,
|
|
254
|
+
) -> dict[str, Any]:
|
|
255
|
+
"""Resolve generation IDs to artifact objects in input parameters.
|
|
256
|
+
|
|
257
|
+
This function automatically detects artifact fields from the Pydantic schema
|
|
258
|
+
via type introspection, then resolves generation ID strings to typed artifact
|
|
259
|
+
objects before Pydantic validation.
|
|
260
|
+
|
|
261
|
+
The function respects the field's type annotation:
|
|
262
|
+
- If field is `ImageArtifact`, input can be a single ID string → returns single artifact
|
|
263
|
+
- If field is `list[ImageArtifact]`, input can be a list of IDs → returns list of artifacts
|
|
264
|
+
(always a list, even if only one ID is provided)
|
|
265
|
+
|
|
266
|
+
Usage in generator input schema (no special declarations needed!):
|
|
267
|
+
class MyGeneratorInput(BaseModel):
|
|
268
|
+
prompt: str = Field(...)
|
|
269
|
+
image_source: ImageArtifact = Field(...) # Automatically detected
|
|
270
|
+
video_sources: list[VideoArtifact] = Field(...) # Also detected
|
|
271
|
+
|
|
272
|
+
Usage in actors.py:
|
|
273
|
+
# Artifacts are automatically detected and resolved
|
|
274
|
+
resolved_params = await resolve_input_artifacts(
|
|
275
|
+
input_params,
|
|
276
|
+
MyGeneratorInput, # Just pass the schema class
|
|
277
|
+
session,
|
|
278
|
+
tenant_id,
|
|
279
|
+
)
|
|
280
|
+
# Now validate with resolved artifacts
|
|
281
|
+
typed_inputs = MyGeneratorInput.model_validate(resolved_params)
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
input_params: Raw input parameters dictionary (may contain generation IDs)
|
|
285
|
+
schema: Pydantic model class to inspect for artifact fields
|
|
286
|
+
session: Database session for queries
|
|
287
|
+
tenant_id: Tenant ID for access validation
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Updated input_params dictionary with generation IDs resolved to artifacts
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
ValueError: If any generation ID cannot be resolved or validated
|
|
294
|
+
"""
|
|
295
|
+
# Automatically extract artifact fields from schema
|
|
296
|
+
artifact_field_map = extract_artifact_fields(schema)
|
|
297
|
+
|
|
298
|
+
# If no artifact fields, just return original params
|
|
299
|
+
if not artifact_field_map:
|
|
300
|
+
return input_params
|
|
301
|
+
|
|
302
|
+
# Create a new dict with resolved artifacts
|
|
303
|
+
# Use dict constructor to avoid shallow copy issues
|
|
304
|
+
resolved_params = dict(input_params)
|
|
305
|
+
|
|
306
|
+
for field_name, (artifact_class, expects_list) in artifact_field_map.items():
|
|
307
|
+
field_value = resolved_params.get(field_name)
|
|
308
|
+
|
|
309
|
+
# Skip if field is not present
|
|
310
|
+
if field_value is None:
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
# Skip if already artifacts (already resolved)
|
|
314
|
+
if isinstance(field_value, list) and all(
|
|
315
|
+
isinstance(item, artifact_class) for item in field_value
|
|
316
|
+
):
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
# Also check for single artifact (only if field expects a single artifact)
|
|
320
|
+
if not expects_list and isinstance(field_value, artifact_class):
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
# Convert field value to list of UUIDs
|
|
324
|
+
generation_ids: list[str | UUID]
|
|
325
|
+
if isinstance(field_value, str):
|
|
326
|
+
# Single generation ID - convert to list for processing
|
|
327
|
+
generation_ids = [field_value]
|
|
328
|
+
elif isinstance(field_value, list):
|
|
329
|
+
# List of generation IDs - ensure all are strings
|
|
330
|
+
# Convert each item to str to ensure type consistency
|
|
331
|
+
generation_ids = [str(item) for item in field_value]
|
|
332
|
+
else:
|
|
333
|
+
raise ValueError(
|
|
334
|
+
f"Field '{field_name}' must be a generation ID (UUID string) "
|
|
335
|
+
f"or list of generation IDs, got: {type(field_value)}"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Resolve to artifacts
|
|
339
|
+
try:
|
|
340
|
+
artifacts = await resolve_generation_ids_to_artifacts(
|
|
341
|
+
generation_ids, artifact_class, session, tenant_id
|
|
342
|
+
)
|
|
343
|
+
except ValueError as e:
|
|
344
|
+
raise ValueError(f"Failed to resolve field '{field_name}': {e}") from e
|
|
345
|
+
|
|
346
|
+
# Update params with resolved artifacts
|
|
347
|
+
# If field expects a single artifact (not a list), unwrap the first artifact
|
|
348
|
+
# Otherwise, keep as a list (even if there's only one artifact)
|
|
349
|
+
if expects_list:
|
|
350
|
+
resolved_value = artifacts
|
|
351
|
+
else:
|
|
352
|
+
# Field expects a single artifact, so unwrap
|
|
353
|
+
if len(artifacts) != 1:
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f"Field '{field_name}' expects a single artifact, "
|
|
356
|
+
f"but got {len(artifacts)} generation IDs"
|
|
357
|
+
)
|
|
358
|
+
resolved_value = artifacts[0]
|
|
359
|
+
|
|
360
|
+
# Debug logging
|
|
361
|
+
logger.debug(
|
|
362
|
+
"Resolved artifact field",
|
|
363
|
+
field_name=field_name,
|
|
364
|
+
expects_list=expects_list,
|
|
365
|
+
artifacts_type=type(artifacts),
|
|
366
|
+
artifacts_len=len(artifacts),
|
|
367
|
+
resolved_value_type=type(resolved_value),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
resolved_params[field_name] = resolved_value
|
|
371
|
+
|
|
372
|
+
return resolved_params
|
|
@@ -28,16 +28,16 @@ class VideoArtifact(DigitalArtifact):
|
|
|
28
28
|
"""Represents a video file artifact from a generation."""
|
|
29
29
|
|
|
30
30
|
duration: float | None = Field(None, description="Duration in seconds")
|
|
31
|
-
width: int = Field(description="Video width in pixels")
|
|
32
|
-
height: int = Field(description="Video height in pixels")
|
|
31
|
+
width: int | None = Field(None, description="Video width in pixels")
|
|
32
|
+
height: int | None = Field(None, description="Video height in pixels")
|
|
33
33
|
fps: float | None = Field(None, description="Frames per second")
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
class ImageArtifact(DigitalArtifact):
|
|
37
37
|
"""Represents an image file artifact from a generation."""
|
|
38
38
|
|
|
39
|
-
width: int = Field(description="Image width in pixels")
|
|
40
|
-
height: int = Field(description="Image height in pixels")
|
|
39
|
+
width: int | None = Field(None, description="Image width in pixels")
|
|
40
|
+
height: int | None = Field(None, description="Image height in pixels")
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
class TextArtifact(DigitalArtifact):
|
|
@@ -94,8 +94,9 @@ class GeneratorExecutionContext(Protocol):
|
|
|
94
94
|
self,
|
|
95
95
|
storage_url: str,
|
|
96
96
|
format: str,
|
|
97
|
-
width: int,
|
|
98
|
-
height: int,
|
|
97
|
+
width: int | None = None,
|
|
98
|
+
height: int | None = None,
|
|
99
|
+
output_index: int = 0,
|
|
99
100
|
) -> ImageArtifact:
|
|
100
101
|
"""Store an image result to permanent storage."""
|
|
101
102
|
...
|
|
@@ -104,10 +105,11 @@ class GeneratorExecutionContext(Protocol):
|
|
|
104
105
|
self,
|
|
105
106
|
storage_url: str,
|
|
106
107
|
format: str,
|
|
107
|
-
width: int,
|
|
108
|
-
height: int,
|
|
108
|
+
width: int | None = None,
|
|
109
|
+
height: int | None = None,
|
|
109
110
|
duration: float | None = None,
|
|
110
111
|
fps: float | None = None,
|
|
112
|
+
output_index: int = 0,
|
|
111
113
|
) -> VideoArtifact:
|
|
112
114
|
"""Store a video result to permanent storage."""
|
|
113
115
|
...
|
|
@@ -119,6 +121,7 @@ class GeneratorExecutionContext(Protocol):
|
|
|
119
121
|
duration: float | None = None,
|
|
120
122
|
sample_rate: int | None = None,
|
|
121
123
|
channels: int | None = None,
|
|
124
|
+
output_index: int = 0,
|
|
122
125
|
) -> AudioArtifact:
|
|
123
126
|
"""Store an audio result to permanent storage."""
|
|
124
127
|
...
|
|
@@ -127,6 +130,7 @@ class GeneratorExecutionContext(Protocol):
|
|
|
127
130
|
self,
|
|
128
131
|
content: str,
|
|
129
132
|
format: str,
|
|
133
|
+
output_index: int = 0,
|
|
130
134
|
) -> TextArtifact:
|
|
131
135
|
"""Store a text result to permanent storage."""
|
|
132
136
|
...
|
|
@@ -4,9 +4,11 @@ Built-in generator implementations for the Boards system.
|
|
|
4
4
|
This package contains example generators that demonstrate how to integrate
|
|
5
5
|
various AI services using their native SDKs.
|
|
6
6
|
|
|
7
|
+
Generators are organized by provider (Replicate, Fal, OpenAI, etc.).
|
|
8
|
+
|
|
7
9
|
Import this module to automatically register all built-in generators:
|
|
8
10
|
import boards.generators.implementations
|
|
9
11
|
"""
|
|
10
12
|
|
|
11
|
-
# Import all
|
|
12
|
-
from . import
|
|
13
|
+
# Import all provider modules to make them available
|
|
14
|
+
from . import fal, openai, replicate
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Fal.ai provider generators."""
|
|
2
|
+
|
|
3
|
+
from . import audio, image, video
|
|
4
|
+
from .image import (
|
|
5
|
+
FalFluxProUltraGenerator,
|
|
6
|
+
FalNanoBananaEditGenerator,
|
|
7
|
+
FalNanoBananaGenerator,
|
|
8
|
+
)
|
|
9
|
+
from .video import (
|
|
10
|
+
FalKlingVideoV25TurboProTextToVideoGenerator,
|
|
11
|
+
FalSyncLipsyncV2Generator,
|
|
12
|
+
FalVeo31FirstLastFrameToVideoGenerator,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Maintain alphabetical order
|
|
16
|
+
__all__ = [
|
|
17
|
+
# Image generators
|
|
18
|
+
"FalFluxProUltraGenerator",
|
|
19
|
+
"FalNanoBananaEditGenerator",
|
|
20
|
+
"FalNanoBananaGenerator",
|
|
21
|
+
# Video generators
|
|
22
|
+
"FalKlingVideoV25TurboProTextToVideoGenerator",
|
|
23
|
+
"FalSyncLipsyncV2Generator",
|
|
24
|
+
"FalVeo31FirstLastFrameToVideoGenerator",
|
|
25
|
+
]
|