@weirdfingers/baseboards 0.2.0 → 0.3.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 +10 -0
- package/dist/index.js +15 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/api/.env.example +1 -1
- package/templates/api/ARTIFACT_RESOLUTION_GUIDE.md +148 -0
- 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/generators/__init__.py +2 -2
- package/templates/api/src/boards/generators/artifact_resolution.py +380 -0
- package/templates/api/src/boards/generators/base.py +4 -0
- 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 +72 -12
- 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 +173 -17
- package/templates/web/package.json +1 -1
- package/templates/web/src/components/boards/GenerationInput.tsx +154 -52
- package/templates/web/src/components/boards/GeneratorSelector.tsx +2 -1
- 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
|
@@ -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:
|
|
@@ -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
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from uuid import UUID
|
|
5
|
+
from uuid import UUID, uuid4
|
|
6
6
|
|
|
7
7
|
from ..database.connection import get_async_session
|
|
8
8
|
from ..generators import resolution
|
|
@@ -24,12 +24,22 @@ class GeneratorExecutionContext:
|
|
|
24
24
|
storage_manager: StorageManager,
|
|
25
25
|
tenant_id: UUID,
|
|
26
26
|
board_id: UUID,
|
|
27
|
+
user_id: UUID,
|
|
28
|
+
generator_name: str,
|
|
29
|
+
artifact_type: str,
|
|
30
|
+
input_params: dict,
|
|
27
31
|
) -> None:
|
|
28
32
|
self.generation_id = str(generation_id)
|
|
29
33
|
self.publisher = publisher
|
|
30
34
|
self.storage_manager = storage_manager
|
|
31
35
|
self.tenant_id = str(tenant_id)
|
|
32
36
|
self.board_id = str(board_id)
|
|
37
|
+
self.user_id = str(user_id)
|
|
38
|
+
self.generator_name = generator_name
|
|
39
|
+
self.artifact_type = artifact_type
|
|
40
|
+
self.input_params = input_params
|
|
41
|
+
self._batch_id: str | None = None
|
|
42
|
+
self._batch_generations: list[str] = []
|
|
33
43
|
logger.info(
|
|
34
44
|
"Created execution context",
|
|
35
45
|
generation_id=str(generation_id),
|
|
@@ -54,13 +64,32 @@ class GeneratorExecutionContext:
|
|
|
54
64
|
format: str,
|
|
55
65
|
width: int,
|
|
56
66
|
height: int,
|
|
67
|
+
output_index: int = 0,
|
|
57
68
|
) -> ImageArtifact:
|
|
58
|
-
"""Store image generation result.
|
|
59
|
-
|
|
69
|
+
"""Store image generation result.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
storage_url: URL to download the image from
|
|
73
|
+
format: Image format (png, jpg, etc.)
|
|
74
|
+
width: Image width in pixels
|
|
75
|
+
height: Image height in pixels
|
|
76
|
+
output_index: Index of this output in a batch (0 for primary, 1+ for additional)
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
ImageArtifact with the generation_id set appropriately
|
|
80
|
+
"""
|
|
81
|
+
logger.debug(
|
|
82
|
+
"Storing image result",
|
|
83
|
+
generation_id=self.generation_id,
|
|
84
|
+
output_index=output_index,
|
|
85
|
+
)
|
|
60
86
|
try:
|
|
87
|
+
# Determine which generation_id to use
|
|
88
|
+
target_generation_id = await self._get_or_create_generation_for_output(output_index)
|
|
89
|
+
|
|
61
90
|
result = await resolution.store_image_result(
|
|
62
91
|
storage_manager=self.storage_manager,
|
|
63
|
-
generation_id=
|
|
92
|
+
generation_id=target_generation_id,
|
|
64
93
|
tenant_id=self.tenant_id,
|
|
65
94
|
board_id=self.board_id,
|
|
66
95
|
storage_url=storage_url,
|
|
@@ -68,7 +97,11 @@ class GeneratorExecutionContext:
|
|
|
68
97
|
width=width,
|
|
69
98
|
height=height,
|
|
70
99
|
)
|
|
71
|
-
logger.info(
|
|
100
|
+
logger.info(
|
|
101
|
+
"Image result stored",
|
|
102
|
+
generation_id=target_generation_id,
|
|
103
|
+
output_index=output_index,
|
|
104
|
+
)
|
|
72
105
|
return result
|
|
73
106
|
except Exception as e:
|
|
74
107
|
logger.error("Failed to store image result", error=str(e))
|
|
@@ -82,13 +115,34 @@ class GeneratorExecutionContext:
|
|
|
82
115
|
height: int,
|
|
83
116
|
duration: float | None = None,
|
|
84
117
|
fps: float | None = None,
|
|
118
|
+
output_index: int = 0,
|
|
85
119
|
) -> VideoArtifact:
|
|
86
|
-
"""Store video generation result.
|
|
87
|
-
|
|
120
|
+
"""Store video generation result.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
storage_url: URL to download the video from
|
|
124
|
+
format: Video format (mp4, webm, etc.)
|
|
125
|
+
width: Video width in pixels
|
|
126
|
+
height: Video height in pixels
|
|
127
|
+
duration: Video duration in seconds (optional)
|
|
128
|
+
fps: Frames per second (optional)
|
|
129
|
+
output_index: Index of this output in a batch (0 for primary, 1+ for additional)
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
VideoArtifact with the generation_id set appropriately
|
|
133
|
+
"""
|
|
134
|
+
logger.debug(
|
|
135
|
+
"Storing video result",
|
|
136
|
+
generation_id=self.generation_id,
|
|
137
|
+
output_index=output_index,
|
|
138
|
+
)
|
|
88
139
|
try:
|
|
140
|
+
# Determine which generation_id to use
|
|
141
|
+
target_generation_id = await self._get_or_create_generation_for_output(output_index)
|
|
142
|
+
|
|
89
143
|
result = await resolution.store_video_result(
|
|
90
144
|
storage_manager=self.storage_manager,
|
|
91
|
-
generation_id=
|
|
145
|
+
generation_id=target_generation_id,
|
|
92
146
|
tenant_id=self.tenant_id,
|
|
93
147
|
board_id=self.board_id,
|
|
94
148
|
storage_url=storage_url,
|
|
@@ -98,7 +152,11 @@ class GeneratorExecutionContext:
|
|
|
98
152
|
duration=duration,
|
|
99
153
|
fps=fps,
|
|
100
154
|
)
|
|
101
|
-
logger.info(
|
|
155
|
+
logger.info(
|
|
156
|
+
"Video result stored",
|
|
157
|
+
generation_id=target_generation_id,
|
|
158
|
+
output_index=output_index,
|
|
159
|
+
)
|
|
102
160
|
return result
|
|
103
161
|
except Exception as e:
|
|
104
162
|
logger.error("Failed to store video result", error=str(e))
|
|
@@ -111,13 +169,33 @@ class GeneratorExecutionContext:
|
|
|
111
169
|
duration: float | None = None,
|
|
112
170
|
sample_rate: int | None = None,
|
|
113
171
|
channels: int | None = None,
|
|
172
|
+
output_index: int = 0,
|
|
114
173
|
) -> AudioArtifact:
|
|
115
|
-
"""Store audio generation result.
|
|
116
|
-
|
|
174
|
+
"""Store audio generation result.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
storage_url: URL to download the audio from
|
|
178
|
+
format: Audio format (mp3, wav, etc.)
|
|
179
|
+
duration: Audio duration in seconds (optional)
|
|
180
|
+
sample_rate: Sample rate in Hz (optional)
|
|
181
|
+
channels: Number of audio channels (optional)
|
|
182
|
+
output_index: Index of this output in a batch (0 for primary, 1+ for additional)
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
AudioArtifact with the generation_id set appropriately
|
|
186
|
+
"""
|
|
187
|
+
logger.debug(
|
|
188
|
+
"Storing audio result",
|
|
189
|
+
generation_id=self.generation_id,
|
|
190
|
+
output_index=output_index,
|
|
191
|
+
)
|
|
117
192
|
try:
|
|
193
|
+
# Determine which generation_id to use
|
|
194
|
+
target_generation_id = await self._get_or_create_generation_for_output(output_index)
|
|
195
|
+
|
|
118
196
|
result = await resolution.store_audio_result(
|
|
119
197
|
storage_manager=self.storage_manager,
|
|
120
|
-
generation_id=
|
|
198
|
+
generation_id=target_generation_id,
|
|
121
199
|
tenant_id=self.tenant_id,
|
|
122
200
|
board_id=self.board_id,
|
|
123
201
|
storage_url=storage_url,
|
|
@@ -126,7 +204,11 @@ class GeneratorExecutionContext:
|
|
|
126
204
|
sample_rate=sample_rate,
|
|
127
205
|
channels=channels,
|
|
128
206
|
)
|
|
129
|
-
logger.info(
|
|
207
|
+
logger.info(
|
|
208
|
+
"Audio result stored",
|
|
209
|
+
generation_id=target_generation_id,
|
|
210
|
+
output_index=output_index,
|
|
211
|
+
)
|
|
130
212
|
return result
|
|
131
213
|
except Exception as e:
|
|
132
214
|
logger.error("Failed to store audio result", error=str(e))
|
|
@@ -136,19 +218,40 @@ class GeneratorExecutionContext:
|
|
|
136
218
|
self,
|
|
137
219
|
content: str,
|
|
138
220
|
format: str,
|
|
221
|
+
output_index: int = 0,
|
|
139
222
|
) -> TextArtifact:
|
|
140
|
-
"""Store text generation result.
|
|
141
|
-
|
|
223
|
+
"""Store text generation result.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
content: Text content to store
|
|
227
|
+
format: Text format (plain, markdown, html, etc.)
|
|
228
|
+
output_index: Index of this output in a batch (0 for primary, 1+ for additional)
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
TextArtifact with the generation_id set appropriately
|
|
232
|
+
"""
|
|
233
|
+
logger.debug(
|
|
234
|
+
"Storing text result",
|
|
235
|
+
generation_id=self.generation_id,
|
|
236
|
+
output_index=output_index,
|
|
237
|
+
)
|
|
142
238
|
try:
|
|
239
|
+
# Determine which generation_id to use
|
|
240
|
+
target_generation_id = await self._get_or_create_generation_for_output(output_index)
|
|
241
|
+
|
|
143
242
|
result = await resolution.store_text_result(
|
|
144
243
|
storage_manager=self.storage_manager,
|
|
145
|
-
generation_id=
|
|
244
|
+
generation_id=target_generation_id,
|
|
146
245
|
tenant_id=self.tenant_id,
|
|
147
246
|
board_id=self.board_id,
|
|
148
247
|
content=content,
|
|
149
248
|
format=format,
|
|
150
249
|
)
|
|
151
|
-
logger.info(
|
|
250
|
+
logger.info(
|
|
251
|
+
"Text result stored",
|
|
252
|
+
generation_id=target_generation_id,
|
|
253
|
+
output_index=output_index,
|
|
254
|
+
)
|
|
152
255
|
return result
|
|
153
256
|
except Exception as e:
|
|
154
257
|
logger.error("Failed to store text result", error=str(e))
|
|
@@ -186,3 +289,56 @@ class GeneratorExecutionContext:
|
|
|
186
289
|
)
|
|
187
290
|
async with get_async_session() as session:
|
|
188
291
|
await jobs_repo.set_external_job_id(session, self.generation_id, external_id)
|
|
292
|
+
|
|
293
|
+
async def _get_or_create_generation_for_output(self, output_index: int) -> str:
|
|
294
|
+
"""Get or create a generation record for the given output index.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
output_index: Index of the output (0 for primary, 1+ for batch outputs)
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
generation_id to use for storing this output
|
|
301
|
+
"""
|
|
302
|
+
# Index 0 is always the primary generation
|
|
303
|
+
if output_index == 0:
|
|
304
|
+
return self.generation_id
|
|
305
|
+
|
|
306
|
+
# For batch outputs, ensure we have a batch_id
|
|
307
|
+
if self._batch_id is None:
|
|
308
|
+
self._batch_id = str(uuid4())
|
|
309
|
+
logger.debug(
|
|
310
|
+
"Created batch_id for multi-output generation",
|
|
311
|
+
batch_id=self._batch_id,
|
|
312
|
+
primary_generation_id=self.generation_id,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Check if we've already created a generation for this index
|
|
316
|
+
batch_index = output_index - 1 # Adjust since index 0 is primary
|
|
317
|
+
if batch_index < len(self._batch_generations):
|
|
318
|
+
return self._batch_generations[batch_index]
|
|
319
|
+
|
|
320
|
+
# Create new batch generation record
|
|
321
|
+
async with get_async_session() as session:
|
|
322
|
+
batch_gen = await jobs_repo.create_batch_generation(
|
|
323
|
+
session,
|
|
324
|
+
tenant_id=UUID(self.tenant_id),
|
|
325
|
+
board_id=UUID(self.board_id),
|
|
326
|
+
user_id=UUID(self.user_id),
|
|
327
|
+
generator_name=self.generator_name,
|
|
328
|
+
artifact_type=self.artifact_type,
|
|
329
|
+
input_params=self.input_params,
|
|
330
|
+
batch_id=self._batch_id,
|
|
331
|
+
batch_index=output_index,
|
|
332
|
+
)
|
|
333
|
+
await session.commit()
|
|
334
|
+
batch_gen_id = str(batch_gen.id)
|
|
335
|
+
|
|
336
|
+
self._batch_generations.append(batch_gen_id)
|
|
337
|
+
logger.info(
|
|
338
|
+
"Created batch generation record",
|
|
339
|
+
batch_generation_id=batch_gen_id,
|
|
340
|
+
primary_generation_id=self.generation_id,
|
|
341
|
+
batch_id=self._batch_id,
|
|
342
|
+
batch_index=output_index,
|
|
343
|
+
)
|
|
344
|
+
return batch_gen_id
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
|
14
14
|
"@radix-ui/react-slot": "^1.2.3",
|
|
15
15
|
"@tailwindcss/postcss": "^4.1.13",
|
|
16
|
-
"@weirdfingers/boards": "^0.
|
|
16
|
+
"@weirdfingers/boards": "^0.3.0",
|
|
17
17
|
"class-variance-authority": "^0.7.1",
|
|
18
18
|
"clsx": "^2.0.0",
|
|
19
19
|
"graphql": "^16.11.0",
|