@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.
Files changed (56) hide show
  1. package/README.md +14 -4
  2. package/dist/index.js +13 -4
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/api/ARTIFACT_RESOLUTION_GUIDE.md +148 -0
  6. package/templates/api/Dockerfile +2 -2
  7. package/templates/api/README.md +138 -6
  8. package/templates/api/config/generators.yaml +41 -7
  9. package/templates/api/docs/TESTING_LIVE_APIS.md +417 -0
  10. package/templates/api/pyproject.toml +49 -9
  11. package/templates/api/src/boards/__init__.py +1 -1
  12. package/templates/api/src/boards/auth/adapters/__init__.py +9 -2
  13. package/templates/api/src/boards/auth/factory.py +16 -2
  14. package/templates/api/src/boards/generators/__init__.py +2 -2
  15. package/templates/api/src/boards/generators/artifact_resolution.py +372 -0
  16. package/templates/api/src/boards/generators/artifacts.py +4 -4
  17. package/templates/api/src/boards/generators/base.py +8 -4
  18. package/templates/api/src/boards/generators/implementations/__init__.py +4 -2
  19. package/templates/api/src/boards/generators/implementations/fal/__init__.py +25 -0
  20. package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +4 -0
  21. package/templates/api/src/boards/generators/implementations/fal/audio/minimax_music_v2.py +173 -0
  22. package/templates/api/src/boards/generators/implementations/fal/audio/minimax_speech_2_6_turbo.py +221 -0
  23. package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +17 -0
  24. package/templates/api/src/boards/generators/implementations/fal/image/flux_pro_kontext.py +216 -0
  25. package/templates/api/src/boards/generators/implementations/fal/image/flux_pro_ultra.py +197 -0
  26. package/templates/api/src/boards/generators/implementations/fal/image/imagen4_preview.py +191 -0
  27. package/templates/api/src/boards/generators/implementations/fal/image/imagen4_preview_fast.py +179 -0
  28. package/templates/api/src/boards/generators/implementations/fal/image/nano_banana.py +183 -0
  29. package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_edit.py +212 -0
  30. package/templates/api/src/boards/generators/implementations/fal/utils.py +61 -0
  31. package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +13 -0
  32. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_v2_5_turbo_pro_text_to_video.py +168 -0
  33. package/templates/api/src/boards/generators/implementations/fal/video/sync_lipsync_v2.py +167 -0
  34. package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +180 -0
  35. package/templates/api/src/boards/generators/implementations/openai/__init__.py +1 -0
  36. package/templates/api/src/boards/generators/implementations/openai/audio/__init__.py +1 -0
  37. package/templates/api/src/boards/generators/implementations/{audio → openai/audio}/whisper.py +9 -6
  38. package/templates/api/src/boards/generators/implementations/openai/image/__init__.py +1 -0
  39. package/templates/api/src/boards/generators/implementations/{image → openai/image}/dalle3.py +8 -5
  40. package/templates/api/src/boards/generators/implementations/replicate/__init__.py +1 -0
  41. package/templates/api/src/boards/generators/implementations/replicate/image/__init__.py +1 -0
  42. package/templates/api/src/boards/generators/implementations/{image → replicate/image}/flux_pro.py +8 -5
  43. package/templates/api/src/boards/generators/implementations/replicate/video/__init__.py +1 -0
  44. package/templates/api/src/boards/generators/implementations/{video → replicate/video}/lipsync.py +9 -6
  45. package/templates/api/src/boards/generators/resolution.py +80 -20
  46. package/templates/api/src/boards/jobs/repository.py +49 -0
  47. package/templates/api/src/boards/storage/factory.py +16 -6
  48. package/templates/api/src/boards/workers/actors.py +69 -5
  49. package/templates/api/src/boards/workers/context.py +177 -21
  50. package/templates/web/package.json +2 -1
  51. package/templates/web/src/components/boards/GenerationInput.tsx +154 -52
  52. package/templates/web/src/components/boards/GeneratorSelector.tsx +57 -59
  53. package/templates/web/src/components/ui/dropdown-menu.tsx +200 -0
  54. package/templates/api/src/boards/generators/implementations/audio/__init__.py +0 -3
  55. package/templates/api/src/boards/generators/implementations/image/__init__.py +0 -3
  56. 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",
@@ -3,7 +3,7 @@ Boards Backend SDK
3
3
  Open-source creative toolkit for AI-generated content
4
4
  """
5
5
 
6
- __version__ = "0.2.1"
6
+ __version__ = "0.4.0"
7
7
 
8
8
  from .config import settings
9
9
 
@@ -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
- from .adapters.supabase import SupabaseAuthAdapter
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 FluxProGenerator
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 generator modules to trigger registration
12
- from . import audio, image, video
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
+ ]
@@ -0,0 +1,4 @@
1
+ from .minimax_music_v2 import FalMinimaxMusicV2Generator
2
+ from .minimax_speech_2_6_turbo import FalMinimaxSpeech26TurboGenerator
3
+
4
+ __all__ = ["FalMinimaxSpeech26TurboGenerator", "FalMinimaxMusicV2Generator"]