@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
@@ -2,6 +2,7 @@
2
2
  Artifact resolution utilities for converting Generation references to actual files.
3
3
  """
4
4
 
5
+ import base64
5
6
  import os
6
7
  import tempfile
7
8
  import uuid
@@ -153,6 +154,59 @@ def _get_file_extension(
153
154
  return format_ext
154
155
 
155
156
 
157
+ def _decode_data_url(data_url: str) -> bytes:
158
+ """
159
+ Decode a data URL to bytes.
160
+
161
+ Supports data URLs in the format: data:[<mediatype>][;base64],<data>
162
+
163
+ Args:
164
+ data_url: Data URL string (e.g., "data:image/png;base64,iVBORw0KGgo...")
165
+
166
+ Returns:
167
+ bytes: Decoded content
168
+
169
+ Raises:
170
+ ValueError: If data URL is malformed or empty
171
+ """
172
+ if not data_url.startswith("data:"):
173
+ raise ValueError("Invalid data URL: must start with 'data:'")
174
+
175
+ # Split off the "data:" prefix
176
+ try:
177
+ # Format: data:[<mediatype>][;base64],<data>
178
+ header, data = data_url[5:].split(",", 1)
179
+ except ValueError as e:
180
+ raise ValueError("Invalid data URL format: missing comma separator") from e
181
+
182
+ if not data:
183
+ raise ValueError("Data URL contains no data after comma")
184
+
185
+ # Check if base64 encoded
186
+ is_base64 = ";base64" in header
187
+
188
+ if is_base64:
189
+ try:
190
+ decoded = base64.b64decode(data)
191
+ except Exception as e:
192
+ raise ValueError(f"Failed to decode base64 data: {e}") from e
193
+ else:
194
+ # URL-encoded data (rare for binary content)
195
+ from urllib.parse import unquote
196
+
197
+ decoded = unquote(data).encode("utf-8")
198
+
199
+ if len(decoded) == 0:
200
+ raise ValueError("Decoded data URL is empty")
201
+
202
+ logger.info(
203
+ "Successfully decoded data URL",
204
+ size_bytes=len(decoded),
205
+ is_base64=is_base64,
206
+ )
207
+ return decoded
208
+
209
+
156
210
  async def download_from_url(url: str) -> bytes:
157
211
  """
158
212
  Download content from a URL (typically a provider's temporary URL).
@@ -160,20 +214,26 @@ async def download_from_url(url: str) -> bytes:
160
214
  This is used to download generated content from providers like Replicate, OpenAI, etc.
161
215
  before uploading to our permanent storage.
162
216
 
217
+ Supports both HTTP(S) URLs and data URLs (data:mime/type;base64,...)
218
+
163
219
  Note: For very large files, consider using streaming downloads directly to storage
164
220
  instead of loading into memory.
165
221
 
166
222
  Args:
167
- url: URL to download from
223
+ url: URL to download from (HTTP(S) or data URL)
168
224
 
169
225
  Returns:
170
226
  bytes: Downloaded content
171
227
 
172
228
  Raises:
173
229
  httpx.HTTPError: If download fails
174
- ValueError: If downloaded content is empty
230
+ ValueError: If downloaded content is empty or data URL is malformed
175
231
  """
176
- logger.debug("Downloading content from URL", url=url)
232
+ logger.debug("Downloading content from URL", url=url[:50])
233
+
234
+ # Check if this is a data URL
235
+ if url.startswith("data:"):
236
+ return _decode_data_url(url)
177
237
 
178
238
  # Stream download to avoid loading entire file into memory at once
179
239
  async with httpx.AsyncClient(timeout=60.0) as client:
@@ -244,8 +304,8 @@ async def store_image_result(
244
304
  board_id: str,
245
305
  storage_url: str,
246
306
  format: str,
247
- width: int,
248
- height: int,
307
+ width: int | None = None,
308
+ height: int | None = None,
249
309
  ) -> ImageArtifact:
250
310
  """
251
311
  Store an image result by downloading from provider URL and uploading to storage.
@@ -257,8 +317,8 @@ async def store_image_result(
257
317
  board_id: Board ID for organization
258
318
  storage_url: Provider's temporary URL to download from
259
319
  format: Image format (png, jpg, etc.)
260
- width: Image width in pixels
261
- height: Image height in pixels
320
+ width: Image width in pixels (optional)
321
+ height: Image height in pixels (optional)
262
322
 
263
323
  Returns:
264
324
  ImageArtifact with permanent storage URL
@@ -270,7 +330,7 @@ async def store_image_result(
270
330
  logger.info(
271
331
  "Storing image result",
272
332
  generation_id=generation_id,
273
- provider_url=storage_url,
333
+ provider_url=storage_url[:50],
274
334
  format=format,
275
335
  )
276
336
 
@@ -294,7 +354,7 @@ async def store_image_result(
294
354
  "Image stored successfully",
295
355
  generation_id=generation_id,
296
356
  storage_key=artifact_ref.storage_key,
297
- storage_url=artifact_ref.storage_url,
357
+ storage_url=artifact_ref.storage_url[:50],
298
358
  )
299
359
 
300
360
  # Return artifact with our permanent storage URL
@@ -314,8 +374,8 @@ async def store_video_result(
314
374
  board_id: str,
315
375
  storage_url: str,
316
376
  format: str,
317
- width: int,
318
- height: int,
377
+ width: int | None = None,
378
+ height: int | None = None,
319
379
  duration: float | None = None,
320
380
  fps: float | None = None,
321
381
  ) -> VideoArtifact:
@@ -329,8 +389,8 @@ async def store_video_result(
329
389
  board_id: Board ID for organization
330
390
  storage_url: Provider's temporary URL to download from
331
391
  format: Video format (mp4, webm, etc.)
332
- width: Video width in pixels
333
- height: Video height in pixels
392
+ width: Video width in pixels (optional)
393
+ height: Video height in pixels (optional)
334
394
  duration: Video duration in seconds (optional)
335
395
  fps: Frames per second (optional)
336
396
 
@@ -344,7 +404,7 @@ async def store_video_result(
344
404
  logger.info(
345
405
  "Storing video result",
346
406
  generation_id=generation_id,
347
- provider_url=storage_url,
407
+ provider_url=storage_url[:50],
348
408
  format=format,
349
409
  )
350
410
 
@@ -368,7 +428,7 @@ async def store_video_result(
368
428
  "Video stored successfully",
369
429
  generation_id=generation_id,
370
430
  storage_key=artifact_ref.storage_key,
371
- storage_url=artifact_ref.storage_url,
431
+ storage_url=artifact_ref.storage_url[:50],
372
432
  )
373
433
 
374
434
  # Return artifact with our permanent storage URL
@@ -418,7 +478,7 @@ async def store_audio_result(
418
478
  logger.info(
419
479
  "Storing audio result",
420
480
  generation_id=generation_id,
421
- provider_url=storage_url,
481
+ provider_url=storage_url[:50],
422
482
  format=format,
423
483
  )
424
484
 
@@ -442,7 +502,7 @@ async def store_audio_result(
442
502
  "Audio stored successfully",
443
503
  generation_id=generation_id,
444
504
  storage_key=artifact_ref.storage_key,
445
- storage_url=artifact_ref.storage_url,
505
+ storage_url=artifact_ref.storage_url[:50],
446
506
  )
447
507
 
448
508
  # Return artifact with our permanent storage URL
@@ -485,7 +545,7 @@ async def store_text_result(
485
545
  logger.info(
486
546
  "Storing text result",
487
547
  generation_id=generation_id,
488
- content=content,
548
+ content=content[:50],
489
549
  format=format,
490
550
  )
491
551
 
@@ -503,13 +563,13 @@ async def store_text_result(
503
563
  "Text stored successfully",
504
564
  generation_id=generation_id,
505
565
  storage_key=artifact_ref.storage_key,
506
- storage_url=artifact_ref.storage_url,
566
+ storage_url=artifact_ref.storage_url[:50],
507
567
  )
508
568
 
509
569
  # Return artifact with our permanent storage URL
510
570
  return TextArtifact(
511
571
  generation_id=generation_id,
512
572
  storage_url=artifact_ref.storage_url,
513
- content=content,
573
+ content=content[:50],
514
574
  format=format,
515
575
  )
@@ -102,3 +102,52 @@ async def finalize_success(
102
102
  )
103
103
  )
104
104
  await session.execute(stmt)
105
+
106
+
107
+ async def create_batch_generation(
108
+ session: AsyncSession,
109
+ *,
110
+ tenant_id: UUID,
111
+ board_id: UUID,
112
+ user_id: UUID,
113
+ generator_name: str,
114
+ artifact_type: str,
115
+ input_params: dict,
116
+ batch_id: str,
117
+ batch_index: int,
118
+ ) -> Generations:
119
+ """Create a batch generation record for multi-output generators.
120
+
121
+ This creates a new generation record that is part of a batch, with
122
+ batch metadata stored in output_metadata.
123
+
124
+ Args:
125
+ session: Database session
126
+ tenant_id: Tenant ID
127
+ board_id: Board ID
128
+ user_id: User ID
129
+ generator_name: Name of the generator
130
+ artifact_type: Type of artifact (image, video, etc.)
131
+ input_params: Input parameters (same as primary generation)
132
+ batch_id: Unique ID for this batch
133
+ batch_index: Index of this output in the batch
134
+
135
+ Returns:
136
+ Created generation record
137
+ """
138
+ gen = Generations()
139
+ gen.tenant_id = tenant_id
140
+ gen.board_id = board_id
141
+ gen.user_id = user_id
142
+ gen.generator_name = generator_name
143
+ gen.artifact_type = artifact_type
144
+ gen.input_params = input_params
145
+ gen.status = "processing"
146
+ gen.progress = Decimal(0.0)
147
+ gen.output_metadata = {
148
+ "batch_id": batch_id,
149
+ "batch_index": batch_index,
150
+ }
151
+ session.add(gen)
152
+ await session.flush()
153
+ return gen
@@ -47,7 +47,10 @@ try:
47
47
  except ImportError:
48
48
  SupabaseStorageProvider = None
49
49
  _supabase_available = False
50
- logger.warning("Supabase storage not available - install supabase-py to enable")
50
+ logger.warning(
51
+ "Supabase storage not available. "
52
+ "Install with: pip install weirdfingers-boards[storage-supabase]"
53
+ )
51
54
 
52
55
  try:
53
56
  from .implementations.s3 import S3StorageProvider
@@ -56,7 +59,9 @@ try:
56
59
  except ImportError:
57
60
  S3StorageProvider = None
58
61
  _s3_available = False
59
- logger.warning("S3 storage not available - install boto3 and aioboto3 to enable")
62
+ logger.warning(
63
+ "S3 storage not available. " "Install with: pip install weirdfingers-boards[storage-s3]"
64
+ )
60
65
 
61
66
  try:
62
67
  from .implementations.gcs import GCSStorageProvider
@@ -65,7 +70,9 @@ try:
65
70
  except ImportError:
66
71
  GCSStorageProvider = None
67
72
  _gcs_available = False
68
- logger.warning("GCS storage not available - install google-cloud-storage to enable")
73
+ logger.warning(
74
+ "GCS storage not available. " "Install with: pip install weirdfingers-boards[storage-gcs]"
75
+ )
69
76
 
70
77
 
71
78
  def create_storage_provider(provider_type: str, config: dict[str, Any]) -> StorageProvider:
@@ -88,19 +95,22 @@ def create_storage_provider(provider_type: str, config: dict[str, Any]) -> Stora
88
95
  elif provider_type == "supabase":
89
96
  if not _supabase_available:
90
97
  raise ImportError(
91
- "Supabase storage requires supabase-py. Install with: pip install supabase"
98
+ "Supabase storage requires additional dependencies. "
99
+ "Install with: pip install weirdfingers-boards[storage-supabase]"
92
100
  )
93
101
  return _create_supabase_provider(config)
94
102
  elif provider_type == "s3":
95
103
  if not _s3_available:
96
104
  raise ImportError(
97
- "S3 storage requires boto3 and aioboto3. Install with: pip install boto3 aioboto3"
105
+ "S3 storage requires additional dependencies. "
106
+ "Install with: pip install weirdfingers-boards[storage-s3]"
98
107
  )
99
108
  return _create_s3_provider(config)
100
109
  elif provider_type == "gcs":
101
110
  if not _gcs_available:
102
111
  raise ImportError(
103
- "GCS storage package required. Install with: pip install google-cloud-storage"
112
+ "GCS storage requires additional dependencies. "
113
+ "Install with: pip install weirdfingers-boards[storage-gcs]"
104
114
  )
105
115
  return _create_gcs_provider(config)
106
116
  else:
@@ -75,6 +75,8 @@ async def process_generation(generation_id: str) -> None:
75
75
  gen_id = gen.id
76
76
  tenant_id = gen.tenant_id
77
77
  board_id = gen.board_id
78
+ user_id = gen.user_id
79
+ artifact_type = gen.artifact_type
78
80
 
79
81
  # Initialize storage manager
80
82
  # This will use the default storage configuration from environment/config
@@ -88,18 +90,39 @@ async def process_generation(generation_id: str) -> None:
88
90
  raise RuntimeError(f"Unknown generator: {generator_name}")
89
91
 
90
92
  # Build and validate typed inputs
93
+ # First resolve any artifact fields (generation IDs -> artifact objects)
94
+ # This happens automatically via type introspection
91
95
  try:
92
96
  input_schema = generator.get_input_schema()
93
- typed_inputs = input_schema.model_validate(input_params)
97
+
98
+ # Automatically resolve generation IDs to artifacts before validation
99
+ from ..generators.artifact_resolution import resolve_input_artifacts
100
+
101
+ async with get_async_session() as session:
102
+ resolved_params = await resolve_input_artifacts(
103
+ input_params,
104
+ input_schema, # Schema is introspected to find artifact fields
105
+ session,
106
+ tenant_id,
107
+ )
108
+ typed_inputs = input_schema.model_validate(resolved_params)
94
109
  except Exception as e:
95
110
  error_msg = "Invalid input parameters"
96
111
  logger.error(error_msg, generation_id=generation_id, error=str(e))
97
112
  raise ValueError(f"Invalid input parameters: {e}") from e
98
113
 
99
114
  # Build context and run generator
100
- # TODO(generators): make a way for a generator to add additional generations
101
- # based on eg outputs=4, or similar.
102
- context = GeneratorExecutionContext(gen_id, publisher, storage_manager, tenant_id, board_id)
115
+ context = GeneratorExecutionContext(
116
+ gen_id,
117
+ publisher,
118
+ storage_manager,
119
+ tenant_id,
120
+ board_id,
121
+ user_id,
122
+ generator_name,
123
+ artifact_type,
124
+ input_params,
125
+ )
103
126
 
104
127
  await publisher.publish_progress(
105
128
  generation_id,
@@ -125,9 +148,10 @@ async def process_generation(generation_id: str) -> None:
125
148
  "Generator completed successfully",
126
149
  generator_name=generator_name,
127
150
  generation_id=generation_id,
151
+ artifact_count=len(output.outputs),
128
152
  )
129
153
 
130
- # Find the artifact with matching generation_id
154
+ # Find the artifact with matching generation_id (primary generation)
131
155
  # Generators should return exactly one artifact with the matching generation_id
132
156
  matching_artifacts = [art for art in output.outputs if art.generation_id == generation_id]
133
157
 
@@ -150,6 +174,18 @@ async def process_generation(generation_id: str) -> None:
150
174
  storage_url = artifact.storage_url
151
175
  output_metadata = artifact.model_dump()
152
176
 
177
+ # If this was a batch generation, add batch metadata to primary generation
178
+ if context._batch_id is not None:
179
+ output_metadata["batch_id"] = context._batch_id
180
+ output_metadata["batch_index"] = 0
181
+ output_metadata["batch_size"] = len(output.outputs)
182
+ logger.info(
183
+ "Primary generation is part of batch",
184
+ generation_id=generation_id,
185
+ batch_id=context._batch_id,
186
+ batch_size=len(output.outputs),
187
+ )
188
+
153
189
  # Finalize DB with storage URL and output metadata
154
190
  async with get_async_session() as session:
155
191
  await jobs_repo.finalize_success(
@@ -159,6 +195,34 @@ async def process_generation(generation_id: str) -> None:
159
195
  output_metadata=output_metadata,
160
196
  )
161
197
 
198
+ # Finalize all batch generation records (if any)
199
+ if context._batch_id is not None:
200
+ batch_artifacts = [art for art in output.outputs if art.generation_id != generation_id]
201
+ logger.info(
202
+ "Finalizing batch generation records",
203
+ batch_id=context._batch_id,
204
+ batch_count=len(batch_artifacts),
205
+ )
206
+ for batch_artifact in batch_artifacts:
207
+ async with get_async_session() as session:
208
+ batch_metadata = batch_artifact.model_dump()
209
+ # Add batch metadata to each batch generation
210
+ batch_metadata["batch_id"] = context._batch_id
211
+ # batch_index was set during generation creation via create_batch_generation()
212
+ batch_metadata["batch_size"] = len(output.outputs)
213
+
214
+ await jobs_repo.finalize_success(
215
+ session,
216
+ batch_artifact.generation_id,
217
+ storage_url=batch_artifact.storage_url,
218
+ output_metadata=batch_metadata,
219
+ )
220
+ logger.info(
221
+ "Batch generation finalized",
222
+ batch_generation_id=batch_artifact.generation_id,
223
+ batch_id=context._batch_id,
224
+ )
225
+
162
226
  logger.info("Job finalized successfully", generation_id=generation_id)
163
227
 
164
228
  # Publish completion (DB already updated by finalize_success)