@weirdfingers/baseboards 0.6.2 → 0.7.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/dist/index.js +54 -28
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/README.md +2 -0
- package/templates/api/.env.example +3 -0
- package/templates/api/config/generators.yaml +58 -0
- package/templates/api/pyproject.toml +1 -1
- package/templates/api/src/boards/__init__.py +1 -1
- package/templates/api/src/boards/api/endpoints/storage.py +85 -4
- package/templates/api/src/boards/api/endpoints/uploads.py +1 -2
- package/templates/api/src/boards/database/connection.py +98 -58
- package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +4 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_text_to_speech.py +176 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_tts_turbo.py +195 -0
- package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +14 -0
- package/templates/api/src/boards/generators/implementations/fal/image/bytedance_seedream_v45_edit.py +219 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image_edit.py +208 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_15_edit.py +216 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_5.py +177 -0
- package/templates/api/src/boards/generators/implementations/fal/image/reve_edit.py +178 -0
- package/templates/api/src/boards/generators/implementations/fal/image/reve_text_to_image.py +155 -0
- package/templates/api/src/boards/generators/implementations/fal/image/seedream_v45_text_to_image.py +180 -0
- package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +18 -0
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_pro.py +168 -0
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_standard.py +159 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veed_fabric_1_0.py +180 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31.py +190 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast.py +190 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast_image_to_video.py +191 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +13 -6
- package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_image_to_video.py +212 -0
- package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_text_to_video.py +208 -0
- package/templates/api/src/boards/generators/implementations/kie/__init__.py +11 -0
- package/templates/api/src/boards/generators/implementations/kie/base.py +316 -0
- package/templates/api/src/boards/generators/implementations/kie/image/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/kie/image/nano_banana_edit.py +190 -0
- package/templates/api/src/boards/generators/implementations/kie/utils.py +98 -0
- package/templates/api/src/boards/generators/implementations/kie/video/__init__.py +8 -0
- package/templates/api/src/boards/generators/implementations/kie/video/veo3.py +161 -0
- package/templates/api/src/boards/graphql/resolvers/upload.py +1 -1
- package/templates/web/package.json +4 -1
- package/templates/web/src/app/boards/[boardId]/page.tsx +156 -24
- package/templates/web/src/app/globals.css +3 -0
- package/templates/web/src/app/layout.tsx +15 -5
- package/templates/web/src/components/boards/ArtifactInputSlots.tsx +9 -9
- package/templates/web/src/components/boards/ArtifactPreview.tsx +34 -18
- package/templates/web/src/components/boards/GenerationGrid.tsx +101 -7
- package/templates/web/src/components/boards/GenerationInput.tsx +21 -21
- package/templates/web/src/components/boards/GeneratorSelector.tsx +232 -30
- package/templates/web/src/components/boards/UploadArtifact.tsx +385 -75
- package/templates/web/src/components/header.tsx +3 -1
- package/templates/web/src/components/theme-provider.tsx +10 -0
- package/templates/web/src/components/theme-toggle.tsx +75 -0
- package/templates/web/src/components/ui/alert-dialog.tsx +157 -0
- package/templates/web/src/components/ui/toast.tsx +128 -0
- package/templates/web/src/components/ui/toaster.tsx +35 -0
- package/templates/web/src/components/ui/use-toast.ts +186 -0
|
@@ -17,6 +17,12 @@ generators:
|
|
|
17
17
|
- class: "boards.generators.implementations.fal.audio.beatoven_sound_effect_generation.FalBeatovenSoundEffectGenerationGenerator"
|
|
18
18
|
enabled: true
|
|
19
19
|
|
|
20
|
+
- class: "boards.generators.implementations.fal.audio.chatterbox_text_to_speech.FalChatterboxTextToSpeechGenerator"
|
|
21
|
+
enabled: true
|
|
22
|
+
|
|
23
|
+
- class: "boards.generators.implementations.fal.audio.chatterbox_tts_turbo.FalChatterboxTtsTurboGenerator"
|
|
24
|
+
enabled: true
|
|
25
|
+
|
|
20
26
|
- class: "boards.generators.implementations.fal.image.clarity_upscaler.FalClarityUpscalerGenerator"
|
|
21
27
|
enabled: true
|
|
22
28
|
|
|
@@ -32,6 +38,9 @@ generators:
|
|
|
32
38
|
- class: "boards.generators.implementations.fal.video.bytedance_seedance_v1_pro_text_to_video.FalBytedanceSeedanceV1ProTextToVideoGenerator"
|
|
33
39
|
enabled: true
|
|
34
40
|
|
|
41
|
+
- class: "boards.generators.implementations.fal.image.bytedance_seedream_v45_edit.FalBytedanceSeedreamV45EditGenerator"
|
|
42
|
+
enabled: true
|
|
43
|
+
|
|
35
44
|
- class: "boards.generators.implementations.fal.audio.elevenlabs_tts_eleven_v3.FalElevenlabsTtsElevenV3Generator"
|
|
36
45
|
enabled: true
|
|
37
46
|
|
|
@@ -59,12 +68,21 @@ generators:
|
|
|
59
68
|
- class: "boards.generators.implementations.fal.image.gemini_25_flash_image.FalGemini25FlashImageGenerator"
|
|
60
69
|
enabled: true
|
|
61
70
|
|
|
71
|
+
- class: "boards.generators.implementations.fal.image.gemini_25_flash_image_edit.FalGemini25FlashImageEditGenerator"
|
|
72
|
+
enabled: true
|
|
73
|
+
|
|
74
|
+
- class: "boards.generators.implementations.fal.image.gpt_image_1_5.FalGptImage15Generator"
|
|
75
|
+
enabled: true
|
|
76
|
+
|
|
62
77
|
- class: "boards.generators.implementations.fal.image.gpt_image_1_edit_image.FalGptImage1EditImageGenerator"
|
|
63
78
|
enabled: true
|
|
64
79
|
|
|
65
80
|
- class: "boards.generators.implementations.fal.image.gpt_image_1_mini.FalGptImage1MiniGenerator"
|
|
66
81
|
enabled: true
|
|
67
82
|
|
|
83
|
+
- class: "boards.generators.implementations.fal.image.gpt_image_15_edit.FalGptImage15EditGenerator"
|
|
84
|
+
enabled: true
|
|
85
|
+
|
|
68
86
|
- class: "boards.generators.implementations.fal.image.fal_ideogram_character.FalIdeogramCharacterGenerator"
|
|
69
87
|
enabled: true
|
|
70
88
|
|
|
@@ -86,6 +104,12 @@ generators:
|
|
|
86
104
|
- class: "boards.generators.implementations.fal.video.infinitalk.FalInfinitalkGenerator"
|
|
87
105
|
enabled: true
|
|
88
106
|
|
|
107
|
+
- class: "boards.generators.implementations.fal.video.kling_video_ai_avatar_v2_pro.FalKlingVideoAiAvatarV2ProGenerator"
|
|
108
|
+
enabled: true
|
|
109
|
+
|
|
110
|
+
- class: "boards.generators.implementations.fal.video.kling_video_ai_avatar_v2_standard.FalKlingVideoAiAvatarV2StandardGenerator"
|
|
111
|
+
enabled: true
|
|
112
|
+
|
|
89
113
|
- class: "boards.generators.implementations.fal.video.kling_video_v2_5_turbo_pro_image_to_video.FalKlingVideoV25TurboProImageToVideoGenerator"
|
|
90
114
|
enabled: true
|
|
91
115
|
|
|
@@ -128,6 +152,15 @@ generators:
|
|
|
128
152
|
- class: "boards.generators.implementations.fal.image.qwen_image.FalQwenImageGenerator"
|
|
129
153
|
enabled: true
|
|
130
154
|
|
|
155
|
+
- class: "boards.generators.implementations.fal.image.reve_edit.FalReveEditGenerator"
|
|
156
|
+
enabled: true
|
|
157
|
+
|
|
158
|
+
- class: "boards.generators.implementations.fal.image.reve_text_to_image.FalReveTextToImageGenerator"
|
|
159
|
+
enabled: true
|
|
160
|
+
|
|
161
|
+
- class: "boards.generators.implementations.fal.image.seedream_v45_text_to_image.FalSeedreamV45TextToImageGenerator"
|
|
162
|
+
enabled: true
|
|
163
|
+
|
|
131
164
|
- class: "boards.generators.implementations.fal.video.sora_2_text_to_video_pro.FalSora2TextToVideoProGenerator"
|
|
132
165
|
enabled: true
|
|
133
166
|
|
|
@@ -143,6 +176,9 @@ generators:
|
|
|
143
176
|
- class: "boards.generators.implementations.fal.video.sync_lipsync_v2.FalSyncLipsyncV2Generator"
|
|
144
177
|
enabled: true
|
|
145
178
|
|
|
179
|
+
- class: "boards.generators.implementations.fal.video.veed_fabric_1_0.FalVeedFabric10Generator"
|
|
180
|
+
enabled: true
|
|
181
|
+
|
|
146
182
|
- class: "boards.generators.implementations.fal.video.veed_lipsync.FalVeedLipsyncGenerator"
|
|
147
183
|
enabled: true
|
|
148
184
|
|
|
@@ -152,6 +188,15 @@ generators:
|
|
|
152
188
|
- class: "boards.generators.implementations.fal.video.veo3.FalVeo3Generator"
|
|
153
189
|
enabled: true
|
|
154
190
|
|
|
191
|
+
- class: "boards.generators.implementations.fal.video.veo31.FalVeo31Generator"
|
|
192
|
+
enabled: true
|
|
193
|
+
|
|
194
|
+
- class: "boards.generators.implementations.fal.video.veo31_fast.FalVeo31FastGenerator"
|
|
195
|
+
enabled: true
|
|
196
|
+
|
|
197
|
+
- class: "boards.generators.implementations.fal.video.veo31_fast_image_to_video.FalVeo31FastImageToVideoGenerator"
|
|
198
|
+
enabled: true
|
|
199
|
+
|
|
155
200
|
- class: "boards.generators.implementations.fal.video.veo31_first_last_frame_to_video.FalVeo31FirstLastFrameToVideoGenerator"
|
|
156
201
|
enabled: true
|
|
157
202
|
|
|
@@ -161,9 +206,22 @@ generators:
|
|
|
161
206
|
- class: "boards.generators.implementations.fal.video.veo31_reference_to_video.FalVeo31ReferenceToVideoGenerator"
|
|
162
207
|
enabled: true
|
|
163
208
|
|
|
209
|
+
- class: "boards.generators.implementations.fal.video.wan_25_preview_image_to_video.FalWan25PreviewImageToVideoGenerator"
|
|
210
|
+
enabled: true
|
|
211
|
+
|
|
212
|
+
- class: "boards.generators.implementations.fal.video.wan_25_preview_text_to_video.FalWan25PreviewTextToVideoGenerator"
|
|
213
|
+
enabled: true
|
|
214
|
+
|
|
164
215
|
- class: "boards.generators.implementations.fal.video.wan_pro_image_to_video.FalWanProImageToVideoGenerator"
|
|
165
216
|
enabled: true
|
|
166
217
|
|
|
218
|
+
# Kie.ai generators
|
|
219
|
+
- class: "boards.generators.implementations.kie.image.nano_banana_edit.KieNanoBananaEditGenerator"
|
|
220
|
+
enabled: true
|
|
221
|
+
|
|
222
|
+
- class: "boards.generators.implementations.kie.video.veo3.KieVeo3Generator"
|
|
223
|
+
enabled: true
|
|
224
|
+
|
|
167
225
|
# OpenAI generators
|
|
168
226
|
- class: "boards.generators.implementations.openai.image.dalle3.OpenAIDallE3Generator"
|
|
169
227
|
enabled: true
|
|
@@ -21,15 +21,57 @@ async def storage_status():
|
|
|
21
21
|
return {"status": "Storage endpoint ready"}
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def _get_extension_from_content_type(content_type: str) -> str:
|
|
25
|
+
"""Get file extension from content type.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
content_type: MIME type (e.g., 'video/mp4', 'image/png', 'audio/mpeg')
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
File extension with dot (e.g., '.mp4', '.png', '.mp3')
|
|
32
|
+
"""
|
|
33
|
+
# Map common content types to extensions
|
|
34
|
+
content_type_map = {
|
|
35
|
+
# Video
|
|
36
|
+
"video/mp4": ".mp4",
|
|
37
|
+
"video/webm": ".webm",
|
|
38
|
+
"video/quicktime": ".mov",
|
|
39
|
+
"video/x-msvideo": ".avi",
|
|
40
|
+
# Image
|
|
41
|
+
"image/png": ".png",
|
|
42
|
+
"image/jpeg": ".jpg",
|
|
43
|
+
"image/jpg": ".jpg",
|
|
44
|
+
"image/webp": ".webp",
|
|
45
|
+
"image/gif": ".gif",
|
|
46
|
+
# Audio
|
|
47
|
+
"audio/mpeg": ".mp3",
|
|
48
|
+
"audio/mp3": ".mp3",
|
|
49
|
+
"audio/wav": ".wav",
|
|
50
|
+
"audio/ogg": ".ogg",
|
|
51
|
+
"audio/aac": ".aac",
|
|
52
|
+
# Text
|
|
53
|
+
"text/plain": ".txt",
|
|
54
|
+
"text/html": ".html",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return content_type_map.get(content_type.lower(), "")
|
|
58
|
+
|
|
59
|
+
|
|
24
60
|
@router.get("/{full_path:path}")
|
|
25
|
-
async def serve_file(full_path: str):
|
|
61
|
+
async def serve_file(full_path: str, download: bool = False, filename: str | None = None):
|
|
26
62
|
"""Serve a file from local storage.
|
|
27
63
|
|
|
28
64
|
This endpoint serves files that were uploaded to local storage.
|
|
29
65
|
The full_path includes the tenant_id/artifact_type/board_id/artifact_id/variant structure.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
full_path: Path to the file in storage
|
|
69
|
+
download: If True, force download with Content-Disposition: attachment
|
|
70
|
+
filename: Optional custom filename (without extension) to use for download
|
|
30
71
|
"""
|
|
31
72
|
try:
|
|
32
|
-
logger.info("Serving file", full_path=full_path)
|
|
73
|
+
logger.info("Serving file", full_path=full_path, download=download, filename=filename)
|
|
74
|
+
|
|
33
75
|
# Create storage manager to get the configured local storage path
|
|
34
76
|
storage_manager = create_storage_manager()
|
|
35
77
|
|
|
@@ -64,8 +106,47 @@ async def serve_file(full_path: str):
|
|
|
64
106
|
if not file_path.is_file():
|
|
65
107
|
raise HTTPException(status_code=400, detail="Path is not a file")
|
|
66
108
|
|
|
67
|
-
#
|
|
68
|
-
|
|
109
|
+
# Determine the proper filename with extension
|
|
110
|
+
base_filename = filename if filename else file_path.stem
|
|
111
|
+
final_filename = file_path.name
|
|
112
|
+
has_extension = False
|
|
113
|
+
|
|
114
|
+
# Try to get metadata from storage to determine content type and proper extension
|
|
115
|
+
try:
|
|
116
|
+
metadata = await local_provider.get_metadata(full_path)
|
|
117
|
+
content_type = metadata.get("content_type")
|
|
118
|
+
|
|
119
|
+
if content_type:
|
|
120
|
+
extension = _get_extension_from_content_type(content_type)
|
|
121
|
+
if extension:
|
|
122
|
+
# Use custom filename if provided, otherwise use file stem
|
|
123
|
+
final_filename = f"{base_filename}{extension}"
|
|
124
|
+
has_extension = True
|
|
125
|
+
logger.info(
|
|
126
|
+
"Determined filename from storage metadata",
|
|
127
|
+
original=file_path.name,
|
|
128
|
+
new_filename=final_filename,
|
|
129
|
+
content_type=content_type,
|
|
130
|
+
custom_filename=filename,
|
|
131
|
+
)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
# Log but don't fail if we can't get metadata
|
|
134
|
+
logger.warning("Failed to get storage metadata", path=full_path, error=str(e))
|
|
135
|
+
|
|
136
|
+
# Serve the file with proper filename
|
|
137
|
+
# Only set Content-Disposition if:
|
|
138
|
+
# 1. Download is explicitly requested, OR
|
|
139
|
+
# 2. We have a proper extension from metadata
|
|
140
|
+
headers = {}
|
|
141
|
+
if download:
|
|
142
|
+
# Force download with attachment
|
|
143
|
+
headers["Content-Disposition"] = f'attachment; filename="{final_filename}"'
|
|
144
|
+
elif has_extension:
|
|
145
|
+
# We have proper metadata, suggest filename but allow inline preview
|
|
146
|
+
headers["Content-Disposition"] = f'inline; filename="{final_filename}"'
|
|
147
|
+
# else: No Content-Disposition header - let browser decide based on content-type
|
|
148
|
+
|
|
149
|
+
return FileResponse(file_path, filename=final_filename, headers=headers)
|
|
69
150
|
|
|
70
151
|
except HTTPException:
|
|
71
152
|
raise
|
|
@@ -86,8 +86,7 @@ async def upload_artifact_file(
|
|
|
86
86
|
raise HTTPException(
|
|
87
87
|
status_code=400,
|
|
88
88
|
detail=(
|
|
89
|
-
f"File extension '{file_ext}' is not allowed. "
|
|
90
|
-
f"Allowed extensions: {allowed_exts}"
|
|
89
|
+
f"File extension '{file_ext}' is not allowed. Allowed extensions: {allowed_exts}"
|
|
91
90
|
),
|
|
92
91
|
)
|
|
93
92
|
|
|
@@ -8,7 +8,12 @@ from collections.abc import AsyncGenerator, Generator
|
|
|
8
8
|
from contextlib import asynccontextmanager, contextmanager
|
|
9
9
|
|
|
10
10
|
from sqlalchemy import create_engine, text
|
|
11
|
-
from sqlalchemy.ext.asyncio import
|
|
11
|
+
from sqlalchemy.ext.asyncio import (
|
|
12
|
+
AsyncEngine,
|
|
13
|
+
AsyncSession,
|
|
14
|
+
async_sessionmaker,
|
|
15
|
+
create_async_engine,
|
|
16
|
+
)
|
|
12
17
|
from sqlalchemy.orm import Session, sessionmaker
|
|
13
18
|
|
|
14
19
|
from ..config import settings
|
|
@@ -18,11 +23,25 @@ logger = get_logger(__name__)
|
|
|
18
23
|
|
|
19
24
|
# Global shared connection pools (proper FastAPI pattern)
|
|
20
25
|
_engine = None
|
|
21
|
-
_async_engine = None
|
|
22
26
|
_session_local = None
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
_sync_initialized = False
|
|
28
|
+
_sync_init_lock = threading.Lock()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AsyncDBContext(threading.local):
|
|
32
|
+
engine: AsyncEngine | None
|
|
33
|
+
initialized: bool
|
|
34
|
+
session_local: async_sessionmaker[AsyncSession] | None
|
|
35
|
+
lock: threading.Lock
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self.engine = None
|
|
39
|
+
self.initialized = False
|
|
40
|
+
self.session_local = None
|
|
41
|
+
self.lock = threading.Lock() # Per-thread lock for async initialization
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_async_db_ctx = AsyncDBContext()
|
|
26
45
|
|
|
27
46
|
|
|
28
47
|
def get_database_url() -> str:
|
|
@@ -40,12 +59,22 @@ def get_database_url() -> str:
|
|
|
40
59
|
|
|
41
60
|
def reset_database():
|
|
42
61
|
"""Reset database connections (for tests)."""
|
|
43
|
-
global _engine,
|
|
62
|
+
global _engine, _session_local, _sync_initialized
|
|
63
|
+
|
|
64
|
+
# Dispose of sync engine if it exists
|
|
65
|
+
if _engine is not None:
|
|
66
|
+
_engine.dispose()
|
|
67
|
+
|
|
44
68
|
_engine = None
|
|
45
|
-
_async_engine = None
|
|
46
69
|
_session_local = None
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
_sync_initialized = False
|
|
71
|
+
|
|
72
|
+
# Reset async context for current thread
|
|
73
|
+
# Note: async engine disposal must be done with await, so we just clear the reference
|
|
74
|
+
# The engine will be garbage collected when no sessions reference it
|
|
75
|
+
_async_db_ctx.engine = None
|
|
76
|
+
_async_db_ctx.session_local = None
|
|
77
|
+
_async_db_ctx.initialized = False
|
|
49
78
|
|
|
50
79
|
|
|
51
80
|
async def test_database_connection() -> tuple[bool, str | None]:
|
|
@@ -55,11 +84,11 @@ async def test_database_connection() -> tuple[bool, str | None]:
|
|
|
55
84
|
Returns:
|
|
56
85
|
tuple: (success: bool, error_message: str | None)
|
|
57
86
|
"""
|
|
58
|
-
if
|
|
87
|
+
if _async_db_ctx.engine is None:
|
|
59
88
|
return False, "Database engine not initialized"
|
|
60
89
|
|
|
61
90
|
try:
|
|
62
|
-
async with
|
|
91
|
+
async with _async_db_ctx.engine.connect() as conn:
|
|
63
92
|
await conn.execute(text("SELECT 1"))
|
|
64
93
|
return True, None
|
|
65
94
|
except Exception as e:
|
|
@@ -101,48 +130,59 @@ def init_database(database_url: str | None = None, force_reinit: bool = False):
|
|
|
101
130
|
Thread-safe initialization using a lock to prevent race conditions
|
|
102
131
|
when multiple threads attempt to initialize simultaneously.
|
|
103
132
|
"""
|
|
104
|
-
global _engine,
|
|
105
|
-
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
133
|
+
global _engine, _session_local, _sync_initialized
|
|
134
|
+
|
|
135
|
+
# Get the database URL
|
|
136
|
+
db_url = database_url or get_database_url()
|
|
137
|
+
|
|
138
|
+
# Initialize Sync Engine (Global)
|
|
139
|
+
if not _sync_initialized or force_reinit:
|
|
140
|
+
with _sync_init_lock:
|
|
141
|
+
if not _sync_initialized or force_reinit:
|
|
142
|
+
sync_db_url = db_url
|
|
143
|
+
if db_url.startswith("postgresql://"):
|
|
144
|
+
sync_db_url = db_url.replace("postgresql://", "postgresql+psycopg://")
|
|
145
|
+
_engine = create_engine(
|
|
146
|
+
url=sync_db_url,
|
|
147
|
+
pool_size=settings.database_pool_size,
|
|
148
|
+
max_overflow=settings.database_max_overflow,
|
|
149
|
+
echo=settings.sql_echo,
|
|
150
|
+
)
|
|
151
|
+
_session_local = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
|
|
152
|
+
_sync_initialized = True
|
|
153
|
+
logger.info("Sync database initialized", database_url=db_url)
|
|
154
|
+
|
|
155
|
+
# Initialize Async Engine (Thread-Local)
|
|
156
|
+
# Async engines must be thread-local because asyncpg connections are tied to the event loop
|
|
157
|
+
# and cannot be shared across threads/loops.
|
|
158
|
+
if not _async_db_ctx.initialized or force_reinit:
|
|
159
|
+
with _async_db_ctx.lock:
|
|
160
|
+
# Double-check after acquiring lock (another coroutine may have initialized)
|
|
161
|
+
if not _async_db_ctx.initialized or force_reinit:
|
|
162
|
+
if db_url.startswith("postgresql://"):
|
|
163
|
+
async_db_url = db_url.replace("postgresql://", "postgresql+asyncpg://")
|
|
164
|
+
_async_db_ctx.engine = create_async_engine(
|
|
165
|
+
url=async_db_url,
|
|
166
|
+
pool_size=settings.database_pool_size,
|
|
167
|
+
max_overflow=settings.database_max_overflow,
|
|
168
|
+
echo=settings.sql_echo,
|
|
169
|
+
)
|
|
170
|
+
_async_db_ctx.session_local = async_sessionmaker(
|
|
171
|
+
_async_db_ctx.engine,
|
|
172
|
+
class_=AsyncSession,
|
|
173
|
+
autocommit=False,
|
|
174
|
+
autoflush=False,
|
|
175
|
+
)
|
|
176
|
+
_async_db_ctx.initialized = True
|
|
177
|
+
logger.info(
|
|
178
|
+
"Async database initialized for thread",
|
|
179
|
+
thread_id=threading.get_ident(),
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
logger.warning(
|
|
183
|
+
"Non-PostgreSQL URL detected, async engine not initialized",
|
|
184
|
+
url_prefix=db_url.split("://")[0] if "://" in db_url else "unknown",
|
|
185
|
+
)
|
|
146
186
|
|
|
147
187
|
|
|
148
188
|
def get_engine():
|
|
@@ -154,9 +194,9 @@ def get_engine():
|
|
|
154
194
|
|
|
155
195
|
def get_async_engine():
|
|
156
196
|
"""Get the shared async SQLAlchemy engine."""
|
|
157
|
-
if
|
|
197
|
+
if _async_db_ctx.engine is None:
|
|
158
198
|
init_database()
|
|
159
|
-
return
|
|
199
|
+
return _async_db_ctx.engine
|
|
160
200
|
|
|
161
201
|
|
|
162
202
|
@contextmanager
|
|
@@ -182,13 +222,13 @@ def get_session() -> Generator[Session, None, None]:
|
|
|
182
222
|
@asynccontextmanager
|
|
183
223
|
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
|
184
224
|
"""Get a database session (async) from shared pool."""
|
|
185
|
-
if
|
|
225
|
+
if _async_db_ctx.session_local is None:
|
|
186
226
|
init_database()
|
|
187
227
|
|
|
188
|
-
if
|
|
228
|
+
if _async_db_ctx.session_local is None:
|
|
189
229
|
raise RuntimeError("Async database not available (PostgreSQL required)")
|
|
190
230
|
|
|
191
|
-
async with
|
|
231
|
+
async with _async_db_ctx.session_local() as session:
|
|
192
232
|
try:
|
|
193
233
|
yield session
|
|
194
234
|
await session.commit()
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from .beatoven_music_generation import FalBeatovenMusicGenerationGenerator
|
|
2
2
|
from .beatoven_sound_effect_generation import FalBeatovenSoundEffectGenerationGenerator
|
|
3
|
+
from .chatterbox_text_to_speech import FalChatterboxTextToSpeechGenerator
|
|
4
|
+
from .chatterbox_tts_turbo import FalChatterboxTtsTurboGenerator
|
|
3
5
|
from .elevenlabs_sound_effects_v2 import FalElevenlabsSoundEffectsV2Generator
|
|
4
6
|
from .elevenlabs_tts_eleven_v3 import FalElevenlabsTtsElevenV3Generator
|
|
5
7
|
from .fal_elevenlabs_tts_turbo_v2_5 import FalElevenlabsTtsTurboV25Generator
|
|
@@ -10,6 +12,8 @@ from .minimax_speech_2_6_turbo import FalMinimaxSpeech26TurboGenerator
|
|
|
10
12
|
__all__ = [
|
|
11
13
|
"FalBeatovenMusicGenerationGenerator",
|
|
12
14
|
"FalBeatovenSoundEffectGenerationGenerator",
|
|
15
|
+
"FalChatterboxTextToSpeechGenerator",
|
|
16
|
+
"FalChatterboxTtsTurboGenerator",
|
|
13
17
|
"FalElevenlabsSoundEffectsV2Generator",
|
|
14
18
|
"FalElevenlabsTtsElevenV3Generator",
|
|
15
19
|
"FalElevenlabsTtsTurboV25Generator",
|
package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_text_to_speech.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fal.ai Chatterbox Text-to-Speech generator.
|
|
3
|
+
|
|
4
|
+
Generate expressive speech from text using Resemble AI's Chatterbox model.
|
|
5
|
+
Supports emotive tags for natural expressions like laughing, sighing, and more.
|
|
6
|
+
|
|
7
|
+
Based on Fal AI's fal-ai/chatterbox/text-to-speech model.
|
|
8
|
+
See: https://fal.ai/models/fal-ai/chatterbox/text-to-speech
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from ....base import BaseGenerator, GeneratorExecutionContext, GeneratorResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ChatterboxTextToSpeechInput(BaseModel):
|
|
19
|
+
"""Input schema for Chatterbox text-to-speech generation.
|
|
20
|
+
|
|
21
|
+
Supports emotive tags: <laugh>, <chuckle>, <sigh>, <cough>,
|
|
22
|
+
<sniffle>, <groan>, <yawn>, <gasp>
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
text: str = Field(
|
|
26
|
+
description=(
|
|
27
|
+
"The text to be converted to speech. You can add emotive tags like "
|
|
28
|
+
"<laugh>, <chuckle>, <sigh>, <cough>, <sniffle>, <groan>, <yawn>, <gasp>"
|
|
29
|
+
),
|
|
30
|
+
min_length=1,
|
|
31
|
+
)
|
|
32
|
+
audio_url: str | None = Field(
|
|
33
|
+
default=None,
|
|
34
|
+
description=(
|
|
35
|
+
"Reference audio file URL for voice style matching. "
|
|
36
|
+
"If not provided, uses a default voice sample."
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
exaggeration: float = Field(
|
|
40
|
+
default=0.25,
|
|
41
|
+
ge=0.0,
|
|
42
|
+
le=1.0,
|
|
43
|
+
description="Speech exaggeration intensity factor (0.0 to 1.0)",
|
|
44
|
+
)
|
|
45
|
+
temperature: float = Field(
|
|
46
|
+
default=0.7,
|
|
47
|
+
ge=0.05,
|
|
48
|
+
le=2.0,
|
|
49
|
+
description="Creativity level for generation (0.05 to 2.0)",
|
|
50
|
+
)
|
|
51
|
+
cfg: float = Field(
|
|
52
|
+
default=0.5,
|
|
53
|
+
ge=0.1,
|
|
54
|
+
le=1.0,
|
|
55
|
+
description="Configuration parameter for generation (0.1 to 1.0)",
|
|
56
|
+
)
|
|
57
|
+
seed: int | None = Field(
|
|
58
|
+
default=None,
|
|
59
|
+
description="Random seed for reproducible audio generation",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FalChatterboxTextToSpeechGenerator(BaseGenerator):
|
|
64
|
+
"""Chatterbox text-to-speech generator using fal.ai.
|
|
65
|
+
|
|
66
|
+
Leverages Resemble AI's Chatterbox model to generate expressive speech
|
|
67
|
+
with support for emotive tags and voice cloning via reference audio.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
name = "fal-chatterbox-text-to-speech"
|
|
71
|
+
artifact_type = "audio"
|
|
72
|
+
description = (
|
|
73
|
+
"Fal: Chatterbox TTS - Expressive text-to-speech with emotive tags and voice cloning"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def get_input_schema(self) -> type[ChatterboxTextToSpeechInput]:
|
|
77
|
+
return ChatterboxTextToSpeechInput
|
|
78
|
+
|
|
79
|
+
async def generate(
|
|
80
|
+
self, inputs: ChatterboxTextToSpeechInput, context: GeneratorExecutionContext
|
|
81
|
+
) -> GeneratorResult:
|
|
82
|
+
"""Generate audio using fal.ai Chatterbox text-to-speech model."""
|
|
83
|
+
# Check for API key (fal-client uses FAL_KEY environment variable)
|
|
84
|
+
if not os.getenv("FAL_KEY"):
|
|
85
|
+
raise ValueError("API configuration invalid. Missing FAL_KEY environment variable")
|
|
86
|
+
|
|
87
|
+
# Import fal_client
|
|
88
|
+
try:
|
|
89
|
+
import fal_client
|
|
90
|
+
except ImportError as e:
|
|
91
|
+
raise ImportError(
|
|
92
|
+
"fal.ai SDK is required for FalChatterboxTextToSpeechGenerator. "
|
|
93
|
+
"Install with: pip install weirdfingers-boards[generators-fal]"
|
|
94
|
+
) from e
|
|
95
|
+
|
|
96
|
+
# Prepare arguments for fal.ai API
|
|
97
|
+
arguments: dict = {
|
|
98
|
+
"text": inputs.text,
|
|
99
|
+
"exaggeration": inputs.exaggeration,
|
|
100
|
+
"temperature": inputs.temperature,
|
|
101
|
+
"cfg": inputs.cfg,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Add optional parameters if provided
|
|
105
|
+
if inputs.audio_url is not None:
|
|
106
|
+
arguments["audio_url"] = inputs.audio_url
|
|
107
|
+
if inputs.seed is not None:
|
|
108
|
+
arguments["seed"] = inputs.seed
|
|
109
|
+
|
|
110
|
+
# Submit async job and get handler
|
|
111
|
+
handler = await fal_client.submit_async(
|
|
112
|
+
"fal-ai/chatterbox/text-to-speech",
|
|
113
|
+
arguments=arguments,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Store the external job ID for tracking
|
|
117
|
+
await context.set_external_job_id(handler.request_id)
|
|
118
|
+
|
|
119
|
+
# Stream progress updates (sample every 3rd event to avoid spam)
|
|
120
|
+
from .....progress.models import ProgressUpdate
|
|
121
|
+
|
|
122
|
+
event_count = 0
|
|
123
|
+
async for event in handler.iter_events(with_logs=True):
|
|
124
|
+
event_count += 1
|
|
125
|
+
|
|
126
|
+
# Process every 3rd event to provide feedback without overwhelming
|
|
127
|
+
if event_count % 3 == 0:
|
|
128
|
+
# Extract logs if available
|
|
129
|
+
logs = getattr(event, "logs", None)
|
|
130
|
+
if logs:
|
|
131
|
+
# Join log entries into a single message
|
|
132
|
+
if isinstance(logs, list):
|
|
133
|
+
message = " | ".join(str(log) for log in logs if log)
|
|
134
|
+
else:
|
|
135
|
+
message = str(logs)
|
|
136
|
+
|
|
137
|
+
if message:
|
|
138
|
+
await context.publish_progress(
|
|
139
|
+
ProgressUpdate(
|
|
140
|
+
job_id=handler.request_id,
|
|
141
|
+
status="processing",
|
|
142
|
+
progress=50.0, # Approximate mid-point progress
|
|
143
|
+
phase="processing",
|
|
144
|
+
message=message,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Get final result
|
|
149
|
+
result = await handler.get()
|
|
150
|
+
|
|
151
|
+
# Extract audio URL from result
|
|
152
|
+
# fal.ai returns: {"audio": {"url": "..."}}
|
|
153
|
+
audio_data = result.get("audio")
|
|
154
|
+
if audio_data is None:
|
|
155
|
+
raise ValueError("No audio data returned from fal.ai API")
|
|
156
|
+
|
|
157
|
+
audio_url = audio_data.get("url")
|
|
158
|
+
if not audio_url:
|
|
159
|
+
raise ValueError("Audio URL missing in fal.ai response")
|
|
160
|
+
|
|
161
|
+
# Store audio result
|
|
162
|
+
artifact = await context.store_audio_result(
|
|
163
|
+
storage_url=audio_url,
|
|
164
|
+
format="mp3", # Chatterbox returns MP3 format
|
|
165
|
+
output_index=0,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return GeneratorResult(outputs=[artifact])
|
|
169
|
+
|
|
170
|
+
async def estimate_cost(self, inputs: ChatterboxTextToSpeechInput) -> float:
|
|
171
|
+
"""Estimate cost for Chatterbox text-to-speech generation.
|
|
172
|
+
|
|
173
|
+
Chatterbox pricing is approximately $0.03 per generation.
|
|
174
|
+
"""
|
|
175
|
+
# Fixed cost per generation
|
|
176
|
+
return 0.03
|