@weirdfingers/baseboards 0.4.1 → 0.5.1

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.
@@ -0,0 +1,183 @@
1
+ """
2
+ Google Veo 3.1 image-to-video generator.
3
+
4
+ Converts static images into animated videos based on text prompts using
5
+ Google's Veo 3.1 technology via fal.ai.
6
+
7
+ Based on Fal AI's fal-ai/veo3.1/image-to-video model.
8
+ See: https://fal.ai/models/fal-ai/veo3.1/image-to-video
9
+ """
10
+
11
+ import os
12
+ from typing import Literal
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ from ....artifacts import ImageArtifact
17
+ from ....base import BaseGenerator, GeneratorExecutionContext, GeneratorResult
18
+
19
+
20
+ class Veo31ImageToVideoInput(BaseModel):
21
+ """Input schema for Veo 3.1 image-to-video generation.
22
+
23
+ Artifact fields (image) are automatically detected via type introspection
24
+ and resolved from generation IDs to ImageArtifact objects.
25
+ """
26
+
27
+ prompt: str = Field(description="Text prompt describing the desired video content and motion")
28
+ image: ImageArtifact = Field(
29
+ description="Input image to animate. Should be 720p or higher in 16:9 or 9:16 aspect ratio"
30
+ )
31
+ aspect_ratio: Literal["9:16", "16:9"] = Field(
32
+ default="16:9",
33
+ description="Aspect ratio of the generated video",
34
+ )
35
+ duration: Literal["4s", "6s", "8s"] = Field(
36
+ default="8s",
37
+ description="Duration of the generated video in seconds",
38
+ )
39
+ generate_audio: bool = Field(
40
+ default=True,
41
+ description="Whether to generate audio for the video. Disabling uses 50% fewer credits",
42
+ )
43
+ resolution: Literal["720p", "1080p"] = Field(
44
+ default="720p",
45
+ description="Resolution of the generated video",
46
+ )
47
+
48
+
49
+ class FalVeo31ImageToVideoGenerator(BaseGenerator):
50
+ """Generator for creating videos from static images using Google Veo 3.1."""
51
+
52
+ name = "fal-veo31-image-to-video"
53
+ description = "Fal: Veo 3.1 - Convert images to videos with text-guided animation"
54
+ artifact_type = "video"
55
+
56
+ def get_input_schema(self) -> type[Veo31ImageToVideoInput]:
57
+ """Return the input schema for this generator."""
58
+ return Veo31ImageToVideoInput
59
+
60
+ async def generate(
61
+ self, inputs: Veo31ImageToVideoInput, context: GeneratorExecutionContext
62
+ ) -> GeneratorResult:
63
+ """Generate video using fal.ai veo3.1/image-to-video."""
64
+ # Check for API key
65
+ if not os.getenv("FAL_KEY"):
66
+ raise ValueError("API configuration invalid. Missing FAL_KEY environment variable")
67
+
68
+ # Import fal_client
69
+ try:
70
+ import fal_client
71
+ except ImportError as e:
72
+ raise ImportError(
73
+ "fal.ai SDK is required for FalVeo31ImageToVideoGenerator. "
74
+ "Install with: pip install weirdfingers-boards[generators-fal]"
75
+ ) from e
76
+
77
+ # Upload image artifact to Fal's public storage
78
+ # Fal API requires publicly accessible URLs, but our storage_url might be:
79
+ # - Localhost URLs (not publicly accessible)
80
+ # - Private S3 buckets (not publicly accessible)
81
+ # So we upload to Fal's temporary storage first
82
+ from ..utils import upload_artifacts_to_fal
83
+
84
+ image_urls = await upload_artifacts_to_fal([inputs.image], context)
85
+
86
+ # Prepare arguments for fal.ai API
87
+ arguments = {
88
+ "prompt": inputs.prompt,
89
+ "image_url": image_urls[0],
90
+ "aspect_ratio": inputs.aspect_ratio,
91
+ "duration": inputs.duration,
92
+ "generate_audio": inputs.generate_audio,
93
+ "resolution": inputs.resolution,
94
+ }
95
+
96
+ # Submit async job
97
+ handler = await fal_client.submit_async(
98
+ "fal-ai/veo3.1/image-to-video",
99
+ arguments=arguments,
100
+ )
101
+
102
+ # Store external job ID
103
+ await context.set_external_job_id(handler.request_id)
104
+
105
+ # Stream progress updates
106
+ from .....progress.models import ProgressUpdate
107
+
108
+ event_count = 0
109
+ async for event in handler.iter_events(with_logs=True):
110
+ event_count += 1
111
+ # Sample every 3rd event to avoid spam
112
+ if event_count % 3 == 0:
113
+ # Extract logs if available
114
+ logs = getattr(event, "logs", None)
115
+ if logs:
116
+ # Join log entries into a single message
117
+ if isinstance(logs, list):
118
+ message = " | ".join(str(log) for log in logs if log)
119
+ else:
120
+ message = str(logs)
121
+
122
+ if message:
123
+ await context.publish_progress(
124
+ ProgressUpdate(
125
+ job_id=handler.request_id,
126
+ status="processing",
127
+ progress=50.0,
128
+ phase="processing",
129
+ message=message,
130
+ )
131
+ )
132
+
133
+ # Get final result
134
+ result = await handler.get()
135
+
136
+ # Extract video from result
137
+ # Expected structure: {"video": {"url": "...", "content_type": "...", ...}}
138
+ video_data = result.get("video")
139
+ if not video_data:
140
+ raise ValueError("No video returned from fal.ai API")
141
+
142
+ video_url = video_data.get("url")
143
+ if not video_url:
144
+ raise ValueError("Video missing URL in fal.ai response")
145
+
146
+ # Calculate video dimensions based on resolution and aspect ratio
147
+ if inputs.resolution == "720p":
148
+ if inputs.aspect_ratio == "16:9":
149
+ width, height = 1280, 720
150
+ else: # 9:16
151
+ width, height = 720, 1280
152
+ else: # 1080p
153
+ if inputs.aspect_ratio == "16:9":
154
+ width, height = 1920, 1080
155
+ else: # 9:16
156
+ width, height = 1080, 1920
157
+
158
+ # Parse duration from "Xs" format
159
+ duration_seconds = int(inputs.duration.rstrip("s"))
160
+
161
+ artifact = await context.store_video_result(
162
+ storage_url=video_url,
163
+ format="mp4",
164
+ width=width,
165
+ height=height,
166
+ duration=duration_seconds,
167
+ output_index=0,
168
+ )
169
+
170
+ return GeneratorResult(outputs=[artifact])
171
+
172
+ async def estimate_cost(self, inputs: Veo31ImageToVideoInput) -> float:
173
+ """Estimate cost for this generation in USD.
174
+
175
+ Note: Pricing information not available in Fal documentation.
176
+ Using placeholder value that should be updated with actual pricing.
177
+ """
178
+ # TODO: Update with actual pricing from Fal when available
179
+ # Base cost, with 50% reduction if audio is disabled
180
+ base_cost = 0.15 # Placeholder estimate
181
+ if not inputs.generate_audio:
182
+ return base_cost * 0.5
183
+ return base_cost
@@ -0,0 +1,172 @@
1
+ """
2
+ Google Veo 3.1 reference-to-video generator.
3
+
4
+ Generates videos from multiple reference images to maintain consistent subject
5
+ appearance while creating dynamic video content based on text prompts.
6
+
7
+ Based on Fal AI's fal-ai/veo3.1/reference-to-video model.
8
+ See: https://fal.ai/models/fal-ai/veo3.1/reference-to-video
9
+ """
10
+
11
+ import os
12
+ from typing import Literal
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ from ....artifacts import ImageArtifact
17
+ from ....base import BaseGenerator, GeneratorExecutionContext, GeneratorResult
18
+
19
+
20
+ class Veo31ReferenceToVideoInput(BaseModel):
21
+ """Input schema for Veo 3.1 reference-to-video generation.
22
+
23
+ Artifact fields (image_urls) are automatically detected via type
24
+ introspection and resolved from generation IDs to ImageArtifact objects.
25
+ """
26
+
27
+ image_urls: list[ImageArtifact] = Field(
28
+ description="URLs of reference images for consistent subject appearance"
29
+ )
30
+ prompt: str = Field(description="Text description of desired video content")
31
+ duration: Literal["8s"] = Field(
32
+ default="8s",
33
+ description="Duration of the generated video in seconds (currently only 8s is supported)",
34
+ )
35
+ resolution: Literal["720p", "1080p"] = Field(
36
+ default="720p",
37
+ description="Resolution of the generated video",
38
+ )
39
+ generate_audio: bool = Field(
40
+ default=True,
41
+ description="Whether to generate audio for the video. Disabling uses 50% fewer credits",
42
+ )
43
+
44
+
45
+ class FalVeo31ReferenceToVideoGenerator(BaseGenerator):
46
+ """Generator for creating videos from reference images using Google Veo 3.1."""
47
+
48
+ name = "fal-veo31-reference-to-video"
49
+ description = "Fal: Veo 3.1 - Generate videos from reference images with consistent subjects"
50
+ artifact_type = "video"
51
+
52
+ def get_input_schema(self) -> type[Veo31ReferenceToVideoInput]:
53
+ """Return the input schema for this generator."""
54
+ return Veo31ReferenceToVideoInput
55
+
56
+ async def generate(
57
+ self, inputs: Veo31ReferenceToVideoInput, context: GeneratorExecutionContext
58
+ ) -> GeneratorResult:
59
+ """Generate video using fal.ai veo3.1/reference-to-video."""
60
+ # Check for API key
61
+ if not os.getenv("FAL_KEY"):
62
+ raise ValueError("API configuration invalid. Missing FAL_KEY environment variable")
63
+
64
+ # Import fal_client
65
+ try:
66
+ import fal_client
67
+ except ImportError as e:
68
+ raise ImportError(
69
+ "fal.ai SDK is required for FalVeo31ReferenceToVideoGenerator. "
70
+ "Install with: pip install weirdfingers-boards[generators-fal]"
71
+ ) from e
72
+
73
+ # Upload image artifacts to Fal's public storage
74
+ # Fal API requires publicly accessible URLs, but our storage_url might be:
75
+ # - Localhost URLs (not publicly accessible)
76
+ # - Private S3 buckets (not publicly accessible)
77
+ # So we upload to Fal's temporary storage first
78
+ from ..utils import upload_artifacts_to_fal
79
+
80
+ reference_image_urls = await upload_artifacts_to_fal(inputs.image_urls, context)
81
+
82
+ # Prepare arguments for fal.ai API
83
+ arguments = {
84
+ "image_urls": reference_image_urls,
85
+ "prompt": inputs.prompt,
86
+ "duration": inputs.duration,
87
+ "resolution": inputs.resolution,
88
+ "generate_audio": inputs.generate_audio,
89
+ }
90
+
91
+ # Submit async job
92
+ handler = await fal_client.submit_async(
93
+ "fal-ai/veo3.1/reference-to-video",
94
+ arguments=arguments,
95
+ )
96
+
97
+ # Store external job ID
98
+ await context.set_external_job_id(handler.request_id)
99
+
100
+ # Stream progress updates
101
+ from .....progress.models import ProgressUpdate
102
+
103
+ event_count = 0
104
+ async for event in handler.iter_events(with_logs=True):
105
+ event_count += 1
106
+ # Sample every 3rd event to avoid spam
107
+ if event_count % 3 == 0:
108
+ # Extract logs if available
109
+ logs = getattr(event, "logs", None)
110
+ if logs:
111
+ # Join log entries into a single message
112
+ if isinstance(logs, list):
113
+ message = " | ".join(str(log) for log in logs if log)
114
+ else:
115
+ message = str(logs)
116
+
117
+ if message:
118
+ await context.publish_progress(
119
+ ProgressUpdate(
120
+ job_id=handler.request_id,
121
+ status="processing",
122
+ progress=50.0,
123
+ phase="processing",
124
+ message=message,
125
+ )
126
+ )
127
+
128
+ # Get final result
129
+ result = await handler.get()
130
+
131
+ # Extract video from result
132
+ # Expected structure: {"video": {"url": "...", "content_type": "...", ...}}
133
+ video_data = result.get("video")
134
+ if not video_data:
135
+ raise ValueError("No video returned from fal.ai API")
136
+
137
+ video_url = video_data.get("url")
138
+ if not video_url:
139
+ raise ValueError("Video missing URL in fal.ai response")
140
+
141
+ # Store video result
142
+ # Note: Fal API doesn't provide video dimensions/duration in the response,
143
+ # so we'll use defaults based on input parameters
144
+ width = 1280 if inputs.resolution == "720p" else 1920
145
+ height = 720 if inputs.resolution == "720p" else 1080
146
+
147
+ # Parse duration from "8s" format
148
+ duration_seconds = int(inputs.duration.rstrip("s"))
149
+
150
+ artifact = await context.store_video_result(
151
+ storage_url=video_url,
152
+ format="mp4",
153
+ width=width,
154
+ height=height,
155
+ duration=duration_seconds,
156
+ output_index=0,
157
+ )
158
+
159
+ return GeneratorResult(outputs=[artifact])
160
+
161
+ async def estimate_cost(self, inputs: Veo31ReferenceToVideoInput) -> float:
162
+ """Estimate cost for this generation in USD.
163
+
164
+ Note: Pricing information not available in Fal documentation.
165
+ Using placeholder value that should be updated with actual pricing.
166
+ """
167
+ # TODO: Update with actual pricing from Fal when available
168
+ # Base cost, with 50% reduction if audio is disabled
169
+ base_cost = 0.15 # Placeholder estimate
170
+ if not inputs.generate_audio:
171
+ return base_cost * 0.5
172
+ return base_cost
@@ -115,7 +115,7 @@ async def create_batch_generation(
115
115
  input_params: dict,
116
116
  batch_id: str,
117
117
  batch_index: int,
118
- ) -> Generations:
118
+ ) -> str:
119
119
  """Create a batch generation record for multi-output generators.
120
120
 
121
121
  This creates a new generation record that is part of a batch, with
@@ -133,7 +133,7 @@ async def create_batch_generation(
133
133
  batch_index: Index of this output in the batch
134
134
 
135
135
  Returns:
136
- Created generation record
136
+ ID of the created generation record
137
137
  """
138
138
  gen = Generations()
139
139
  gen.tenant_id = tenant_id
@@ -150,4 +150,4 @@ async def create_batch_generation(
150
150
  }
151
151
  session.add(gen)
152
152
  await session.flush()
153
- return gen
153
+ return str(gen.id)
@@ -6,7 +6,12 @@ from uuid import UUID, uuid4
6
6
 
7
7
  from ..database.connection import get_async_session
8
8
  from ..generators import resolution
9
- from ..generators.artifacts import AudioArtifact, ImageArtifact, TextArtifact, VideoArtifact
9
+ from ..generators.artifacts import (
10
+ AudioArtifact,
11
+ ImageArtifact,
12
+ TextArtifact,
13
+ VideoArtifact,
14
+ )
10
15
  from ..jobs import repository as jobs_repo
11
16
  from ..logging import get_logger
12
17
  from ..progress.models import ProgressUpdate
@@ -319,7 +324,7 @@ class GeneratorExecutionContext:
319
324
 
320
325
  # Create new batch generation record
321
326
  async with get_async_session() as session:
322
- batch_gen = await jobs_repo.create_batch_generation(
327
+ batch_gen_id = await jobs_repo.create_batch_generation(
323
328
  session,
324
329
  tenant_id=UUID(self.tenant_id),
325
330
  board_id=UUID(self.board_id),
@@ -331,7 +336,6 @@ class GeneratorExecutionContext:
331
336
  batch_index=output_index,
332
337
  )
333
338
  await session.commit()
334
- batch_gen_id = str(batch_gen.id)
335
339
 
336
340
  self._batch_generations.append(batch_gen_id)
337
341
  logger.info(
@@ -14,7 +14,7 @@
14
14
  "@radix-ui/react-navigation-menu": "^1.2.14",
15
15
  "@radix-ui/react-slot": "^1.2.3",
16
16
  "@tailwindcss/postcss": "^4.1.13",
17
- "@weirdfingers/boards": "^0.4.1",
17
+ "@weirdfingers/boards": "^0.5.1",
18
18
  "class-variance-authority": "^0.7.1",
19
19
  "clsx": "^2.0.0",
20
20
  "graphql": "^16.11.0",
@@ -2,32 +2,27 @@
2
2
 
3
3
  import React from "react";
4
4
  import { useParams } from "next/navigation";
5
- import { useBoard, useGenerators, useGeneration } from "@weirdfingers/boards";
5
+ import { useBoard, useGenerators, useGeneration, GeneratorSelectionProvider } from "@weirdfingers/boards";
6
6
  import { GenerationGrid } from "@/components/boards/GenerationGrid";
7
7
  import { GenerationInput } from "@/components/boards/GenerationInput";
8
8
 
9
9
  export default function BoardPage() {
10
10
  const params = useParams();
11
11
  const boardId = params.boardId as string;
12
- console.log("[BoardPage] Rendering with boardId:", boardId);
13
12
 
14
- const boardHookResult = useBoard(boardId);
15
- console.log("[BoardPage] useBoard result:", boardHookResult);
16
13
  const {
17
14
  board,
18
15
  loading: boardLoading,
19
16
  error: boardError,
20
17
  refresh: refreshBoard,
21
- } = boardHookResult;
18
+ } = useBoard(boardId);
22
19
 
23
20
  // Fetch available generators
24
- const generatorsHookResult = useGenerators();
25
- console.log("[BoardPage] useGenerators result:", generatorsHookResult);
26
21
  const {
27
22
  generators,
28
23
  loading: generatorsLoading,
29
24
  error: generatorsError,
30
- } = generatorsHookResult;
25
+ } = useGenerators();
31
26
 
32
27
  // Use generation hook for submitting generations and real-time progress
33
28
  const {
@@ -38,10 +33,6 @@ export default function BoardPage() {
38
33
  result,
39
34
  } = useGeneration();
40
35
 
41
- console.log("[BoardPage] board:", board);
42
- console.log("[BoardPage] boardError:", boardError);
43
- console.log("[BoardPage] board is null/undefined?", !board);
44
-
45
36
  // Refresh board when a generation completes or fails
46
37
  // MUST be before conditional returns to satisfy Rules of Hooks
47
38
  React.useEffect(() => {
@@ -49,10 +40,6 @@ export default function BoardPage() {
49
40
  progress &&
50
41
  (progress.status === "completed" || progress.status === "failed")
51
42
  ) {
52
- console.log(
53
- "[BoardPage] Generation finished, refreshing board:",
54
- progress.status
55
- );
56
43
  refreshBoard();
57
44
  }
58
45
  }, [progress, refreshBoard]);
@@ -121,12 +108,6 @@ export default function BoardPage() {
121
108
 
122
109
  // Handle loading state
123
110
  if (boardLoading || !board) {
124
- console.log(
125
- "[BoardPage] Showing loading spinner - boardLoading:",
126
- boardLoading,
127
- "board:",
128
- board
129
- );
130
111
  return (
131
112
  <div className="flex items-center justify-center min-h-screen">
132
113
  <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
@@ -134,8 +115,6 @@ export default function BoardPage() {
134
115
  );
135
116
  }
136
117
 
137
- console.log("[BoardPage] Board loaded successfully:", board);
138
-
139
118
  // Filter completed generations that can be used as inputs
140
119
  const availableArtifacts = generations.filter(
141
120
  (gen) => gen.status === "COMPLETED" && gen.storageUrl
@@ -186,47 +165,48 @@ export default function BoardPage() {
186
165
  };
187
166
 
188
167
  return (
189
- <main className="min-h-screen bg-gray-50">
190
- <div className="container mx-auto px-4 py-6 max-w-7xl">
191
- {/* Header */}
192
- <div className="mb-6">
193
- <h1 className="text-3xl font-bold text-gray-900">{board.title}</h1>
194
- {board.description && (
195
- <p className="text-gray-600 mt-2">{board.description}</p>
196
- )}
197
- </div>
198
-
199
- {/* Generation Grid */}
200
- <div className="mb-8">
201
- <GenerationGrid
202
- generations={generations}
203
- onGenerationClick={(gen) => {
204
- console.log("Clicked generation:", gen);
205
- // TODO: Open generation detail modal
206
- }}
207
- />
208
- </div>
209
-
210
- {/* Generation Input */}
211
- <div className="sticky bottom-6 z-10">
212
- {generatorsLoading ? (
213
- <div className="bg-white rounded-lg shadow-lg p-6 text-center">
214
- <p className="text-gray-500">Loading generators...</p>
215
- </div>
216
- ) : generators.length === 0 ? (
217
- <div className="bg-white rounded-lg shadow-lg p-6 text-center">
218
- <p className="text-gray-500">No generators available</p>
219
- </div>
220
- ) : (
221
- <GenerationInput
222
- generators={generators}
223
- availableArtifacts={availableArtifacts}
224
- onSubmit={handleGenerationSubmit}
225
- isGenerating={isGenerating}
168
+ <GeneratorSelectionProvider>
169
+ <main className="min-h-screen bg-gray-50">
170
+ <div className="container mx-auto px-4 py-6 max-w-7xl">
171
+ {/* Header */}
172
+ <div className="mb-6">
173
+ <h1 className="text-3xl font-bold text-gray-900">{board.title}</h1>
174
+ {board.description && (
175
+ <p className="text-gray-600 mt-2">{board.description}</p>
176
+ )}
177
+ </div>
178
+
179
+ {/* Generation Grid */}
180
+ <div className="mb-8">
181
+ <GenerationGrid
182
+ generations={generations}
183
+ onGenerationClick={() => {
184
+ // TODO: Open generation detail modal
185
+ }}
226
186
  />
227
- )}
187
+ </div>
188
+
189
+ {/* Generation Input */}
190
+ <div id="generation-input" className="sticky bottom-6 z-10">
191
+ {generatorsLoading ? (
192
+ <div className="bg-white rounded-lg shadow-lg p-6 text-center">
193
+ <p className="text-gray-500">Loading generators...</p>
194
+ </div>
195
+ ) : generators.length === 0 ? (
196
+ <div className="bg-white rounded-lg shadow-lg p-6 text-center">
197
+ <p className="text-gray-500">No generators available</p>
198
+ </div>
199
+ ) : (
200
+ <GenerationInput
201
+ generators={generators}
202
+ availableArtifacts={availableArtifacts}
203
+ onSubmit={handleGenerationSubmit}
204
+ isGenerating={isGenerating}
205
+ />
206
+ )}
207
+ </div>
228
208
  </div>
229
- </div>
230
- </main>
209
+ </main>
210
+ </GeneratorSelectionProvider>
231
211
  );
232
212
  }