@weirdfingers/baseboards 0.6.2 → 0.8.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 (57) hide show
  1. package/dist/index.js +54 -28
  2. package/dist/index.js.map +1 -1
  3. package/package.json +1 -1
  4. package/templates/README.md +2 -0
  5. package/templates/api/.env.example +3 -0
  6. package/templates/api/config/generators.yaml +58 -0
  7. package/templates/api/pyproject.toml +1 -1
  8. package/templates/api/src/boards/__init__.py +1 -1
  9. package/templates/api/src/boards/api/endpoints/storage.py +85 -4
  10. package/templates/api/src/boards/api/endpoints/uploads.py +1 -2
  11. package/templates/api/src/boards/database/connection.py +98 -58
  12. package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +4 -0
  13. package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_text_to_speech.py +176 -0
  14. package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_tts_turbo.py +195 -0
  15. package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +14 -0
  16. package/templates/api/src/boards/generators/implementations/fal/image/bytedance_seedream_v45_edit.py +219 -0
  17. package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image_edit.py +208 -0
  18. package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_15_edit.py +216 -0
  19. package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_5.py +177 -0
  20. package/templates/api/src/boards/generators/implementations/fal/image/reve_edit.py +178 -0
  21. package/templates/api/src/boards/generators/implementations/fal/image/reve_text_to_image.py +155 -0
  22. package/templates/api/src/boards/generators/implementations/fal/image/seedream_v45_text_to_image.py +180 -0
  23. package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +18 -0
  24. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_pro.py +168 -0
  25. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_standard.py +159 -0
  26. package/templates/api/src/boards/generators/implementations/fal/video/veed_fabric_1_0.py +180 -0
  27. package/templates/api/src/boards/generators/implementations/fal/video/veo31.py +190 -0
  28. package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast.py +190 -0
  29. package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast_image_to_video.py +191 -0
  30. package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +13 -6
  31. package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_image_to_video.py +212 -0
  32. package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_text_to_video.py +208 -0
  33. package/templates/api/src/boards/generators/implementations/kie/__init__.py +11 -0
  34. package/templates/api/src/boards/generators/implementations/kie/base.py +316 -0
  35. package/templates/api/src/boards/generators/implementations/kie/image/__init__.py +3 -0
  36. package/templates/api/src/boards/generators/implementations/kie/image/nano_banana_edit.py +190 -0
  37. package/templates/api/src/boards/generators/implementations/kie/utils.py +98 -0
  38. package/templates/api/src/boards/generators/implementations/kie/video/__init__.py +8 -0
  39. package/templates/api/src/boards/generators/implementations/kie/video/veo3.py +161 -0
  40. package/templates/api/src/boards/graphql/resolvers/upload.py +1 -1
  41. package/templates/web/package.json +4 -1
  42. package/templates/web/src/app/boards/[boardId]/page.tsx +156 -24
  43. package/templates/web/src/app/globals.css +3 -0
  44. package/templates/web/src/app/layout.tsx +15 -5
  45. package/templates/web/src/components/boards/ArtifactInputSlots.tsx +9 -9
  46. package/templates/web/src/components/boards/ArtifactPreview.tsx +34 -18
  47. package/templates/web/src/components/boards/GenerationGrid.tsx +101 -7
  48. package/templates/web/src/components/boards/GenerationInput.tsx +21 -21
  49. package/templates/web/src/components/boards/GeneratorSelector.tsx +232 -30
  50. package/templates/web/src/components/boards/UploadArtifact.tsx +385 -75
  51. package/templates/web/src/components/header.tsx +3 -1
  52. package/templates/web/src/components/theme-provider.tsx +10 -0
  53. package/templates/web/src/components/theme-toggle.tsx +75 -0
  54. package/templates/web/src/components/ui/alert-dialog.tsx +157 -0
  55. package/templates/web/src/components/ui/toast.tsx +128 -0
  56. package/templates/web/src/components/ui/toaster.tsx +35 -0
  57. 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
@@ -46,7 +46,7 @@ dependencies = [
46
46
  "dramatiq[redis,watch]>=1.15.0",
47
47
  "pillow>=10.0.0",
48
48
  "numpy>=1.24.0",
49
- "psycopg2-binary>=2.9.0",
49
+ "psycopg[binary]>=3.1.0",
50
50
  "structlog>=23.0.0",
51
51
  "asyncpg>=0.29.0",
52
52
  "alembic>=1.13.2",
@@ -3,7 +3,7 @@ Boards Backend SDK
3
3
  Open-source creative toolkit for AI-generated content
4
4
  """
5
5
 
6
- __version__ = "0.6.2"
6
+ __version__ = "0.8.0"
7
7
 
8
8
  from .config import settings
9
9
 
@@ -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
- # Serve the file
68
- return FileResponse(file_path)
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 AsyncSession, async_sessionmaker, create_async_engine
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
- _async_session_local = None
24
- _initialized = False
25
- _init_lock = threading.Lock() # Protect initialization from race conditions
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, _async_engine, _session_local, _async_session_local, _initialized
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
- _async_session_local = None
48
- _initialized = False
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 _async_engine is None:
87
+ if _async_db_ctx.engine is None:
59
88
  return False, "Database engine not initialized"
60
89
 
61
90
  try:
62
- async with _async_engine.connect() as conn:
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, _async_engine, _session_local, _async_session_local, _initialized
105
-
106
- # Fast path: already initialized, no lock needed
107
- if _initialized and not force_reinit and database_url is None:
108
- return
109
-
110
- # Slow path: acquire lock for initialization
111
- with _init_lock:
112
- # Double-check after acquiring lock (another thread may have initialized)
113
- if _initialized and not force_reinit and database_url is None:
114
- return
115
-
116
- # Get the database URL
117
- db_url = database_url or get_database_url()
118
-
119
- # Create sync engine
120
- _engine = create_engine(
121
- db_url,
122
- pool_size=settings.database_pool_size,
123
- max_overflow=settings.database_max_overflow,
124
- echo=settings.sql_echo,
125
- )
126
- _session_local = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
127
-
128
- # Create async engine (if PostgreSQL)
129
- if db_url.startswith("postgresql://"):
130
- async_db_url = db_url.replace("postgresql://", "postgresql+asyncpg://")
131
- _async_engine = create_async_engine(
132
- async_db_url,
133
- pool_size=settings.database_pool_size,
134
- max_overflow=settings.database_max_overflow,
135
- echo=settings.sql_echo,
136
- )
137
- _async_session_local = async_sessionmaker(
138
- _async_engine,
139
- class_=AsyncSession,
140
- autocommit=False,
141
- autoflush=False,
142
- )
143
-
144
- _initialized = True
145
- logger.info("Database initialized", database_url=db_url)
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 _async_engine is None:
197
+ if _async_db_ctx.engine is None:
158
198
  init_database()
159
- return _async_engine
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 _async_session_local is None:
225
+ if _async_db_ctx.session_local is None:
186
226
  init_database()
187
227
 
188
- if _async_session_local is None:
228
+ if _async_db_ctx.session_local is None:
189
229
  raise RuntimeError("Async database not available (PostgreSQL required)")
190
230
 
191
- async with _async_session_local() as session:
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",
@@ -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