@weirdfingers/baseboards 0.4.0 → 0.5.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.
@@ -24,6 +24,55 @@ from .artifacts import (
24
24
  logger = get_logger(__name__)
25
25
 
26
26
 
27
+ def _rewrite_storage_url(storage_url: str) -> str:
28
+ """
29
+ Rewrite storage URL for Docker internal networking.
30
+
31
+ Similar to the Next.js imageLoader, this rewrites public API URLs
32
+ to internal Docker network URLs when running in containers.
33
+
34
+ Args:
35
+ storage_url: The original storage URL
36
+
37
+ Returns:
38
+ str: Rewritten URL if internal_api_url is configured, otherwise original URL
39
+ """
40
+ from ..config import settings
41
+
42
+ logger.debug(
43
+ "Checking URL rewriting configuration",
44
+ internal_api_url=settings.internal_api_url,
45
+ storage_url=storage_url[:100] if storage_url else None,
46
+ )
47
+
48
+ if not settings.internal_api_url:
49
+ logger.debug("No internal_api_url configured, skipping URL rewrite")
50
+ return storage_url
51
+
52
+ # Common patterns to replace (localhost and 127.0.0.1 with various ports)
53
+ # In Docker, the public URL is typically http://localhost:8800 or http://localhost:8088
54
+ # We need to replace it with the internal URL (http://api:8800)
55
+ replacements = [
56
+ ("http://localhost:8800", settings.internal_api_url),
57
+ ("http://127.0.0.1:8800", settings.internal_api_url),
58
+ ("http://localhost:8088", settings.internal_api_url),
59
+ ("http://127.0.0.1:8088", settings.internal_api_url),
60
+ ]
61
+
62
+ rewritten_url = storage_url
63
+ for public_pattern, internal_url in replacements:
64
+ if public_pattern in storage_url:
65
+ rewritten_url = storage_url.replace(public_pattern, internal_url)
66
+ logger.info(
67
+ "Rewrote storage URL for internal Docker networking",
68
+ original_url=storage_url,
69
+ rewritten_url=rewritten_url,
70
+ )
71
+ break
72
+
73
+ return rewritten_url
74
+
75
+
27
76
  async def resolve_artifact(
28
77
  artifact: AudioArtifact | VideoArtifact | ImageArtifact | LoRArtifact,
29
78
  ) -> str:
@@ -97,9 +146,17 @@ async def download_artifact_to_temp(
97
146
  os.chmod(temp_path, 0o600)
98
147
 
99
148
  try:
149
+ # Rewrite URL for Docker internal networking
150
+ download_url = _rewrite_storage_url(artifact.storage_url)
151
+
100
152
  # Stream the download to avoid loading large files into memory
101
153
  async with httpx.AsyncClient(timeout=300.0) as client:
102
- async with client.stream("GET", artifact.storage_url) as response:
154
+ logger.info(
155
+ "Attempting to download artifact",
156
+ original_url=artifact.storage_url,
157
+ download_url=download_url,
158
+ )
159
+ async with client.stream("GET", download_url) as response:
103
160
  response.raise_for_status()
104
161
 
105
162
  # Close the file descriptor returned by mkstemp and use aiofiles
@@ -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(
@@ -5,7 +5,6 @@ services:
5
5
  api:
6
6
  volumes:
7
7
  - ./api:/app
8
- - ./data/storage:/app/data/storage
9
8
  environment:
10
9
  - PYTHONUNBUFFERED=1
11
10
  command:
@@ -24,9 +23,9 @@ services:
24
23
  worker:
25
24
  volumes:
26
25
  - ./api:/app
27
- - ./data/storage:/app/data/storage
28
26
  environment:
29
27
  - PYTHONUNBUFFERED=1
28
+ - BOARDS_INTERNAL_API_URL=http://api:8800
30
29
 
31
30
  web:
32
31
  command: sh -c "pnpm install && pnpm dev"
@@ -34,6 +34,8 @@ services:
34
34
  env_file:
35
35
  - docker/.env
36
36
  - api/.env
37
+ volumes:
38
+ - ./data/storage:/app/data/storage
37
39
  depends_on:
38
40
  db:
39
41
  condition: service_healthy
@@ -67,6 +69,10 @@ services:
67
69
  env_file:
68
70
  - docker/.env
69
71
  - api/.env
72
+ volumes:
73
+ - ./data/storage:/app/data/storage
74
+ environment:
75
+ - BOARDS_INTERNAL_API_URL=http://api:8800
70
76
  depends_on:
71
77
  db:
72
78
  condition: service_healthy
@@ -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.0",
17
+ "@weirdfingers/boards": "^0.5.0",
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
  }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import React, { useState } from "react";
3
4
  import { FileVideo, Volume2, X } from "lucide-react";
4
5
  import Image from "next/image";
5
6
 
@@ -29,6 +30,8 @@ export function ArtifactInputSlots({
29
30
  availableArtifacts,
30
31
  onSelectArtifact,
31
32
  }: ArtifactInputSlotsProps) {
33
+ const [dragOverSlot, setDragOverSlot] = useState<string | null>(null);
34
+
32
35
  const getIcon = (type: string) => {
33
36
  switch (type.toLowerCase()) {
34
37
  case "video":
@@ -47,6 +50,56 @@ export function ArtifactInputSlots({
47
50
  );
48
51
  };
49
52
 
53
+ const handleDragOver = (
54
+ e: React.DragEvent,
55
+ slotType: string,
56
+ slotName: string
57
+ ) => {
58
+ e.preventDefault();
59
+ e.stopPropagation();
60
+
61
+ // Check if the dragged artifact type matches the slot type
62
+ try {
63
+ const data = e.dataTransfer.types.includes("application/json");
64
+ if (data) {
65
+ e.dataTransfer.dropEffect = "copy";
66
+ setDragOverSlot(slotName);
67
+ }
68
+ } catch (err) {
69
+ // Ignore errors during drag over
70
+ }
71
+ };
72
+
73
+ const handleDragLeave = (e: React.DragEvent) => {
74
+ e.preventDefault();
75
+ e.stopPropagation();
76
+ setDragOverSlot(null);
77
+ };
78
+
79
+ const handleDrop = (
80
+ e: React.DragEvent,
81
+ slotType: string,
82
+ slotName: string
83
+ ) => {
84
+ e.preventDefault();
85
+ e.stopPropagation();
86
+ setDragOverSlot(null);
87
+
88
+ try {
89
+ const jsonData = e.dataTransfer.getData("application/json");
90
+ if (jsonData) {
91
+ const artifact = JSON.parse(jsonData) as Generation;
92
+
93
+ // Check if artifact type matches slot type
94
+ if (artifact.artifactType.toLowerCase() === slotType.toLowerCase()) {
95
+ onSelectArtifact(slotName, artifact);
96
+ }
97
+ }
98
+ } catch (err) {
99
+ console.error("Error handling drop:", err);
100
+ }
101
+ };
102
+
50
103
  return (
51
104
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
52
105
  {slots.map((slot) => {
@@ -100,11 +153,22 @@ export function ArtifactInputSlots({
100
153
  </div>
101
154
  ) : (
102
155
  // Show slot placeholder
103
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-6 hover:border-gray-400 transition-colors">
156
+ <div
157
+ className={`border-2 border-dashed rounded-lg p-6 transition-all ${
158
+ dragOverSlot === slot.name
159
+ ? "border-orange-500 bg-orange-50"
160
+ : "border-gray-300 hover:border-gray-400"
161
+ }`}
162
+ onDragOver={(e) => handleDragOver(e, slot.type, slot.name)}
163
+ onDragLeave={handleDragLeave}
164
+ onDrop={(e) => handleDrop(e, slot.type, slot.name)}
165
+ >
104
166
  <div className="flex flex-col items-center justify-center text-center">
105
167
  <div className="mb-2">{getIcon(slot.type)}</div>
106
168
  <p className="text-sm font-medium text-gray-700 mb-1">
107
- Add a {slot.type}
169
+ {dragOverSlot === slot.name
170
+ ? `Drop ${slot.type} here`
171
+ : `Add a ${slot.type}`}
108
172
  </p>
109
173
  {matchingArtifacts.length > 0 ? (
110
174
  <select
@@ -128,7 +192,7 @@ export function ArtifactInputSlots({
128
192
  </select>
129
193
  ) : (
130
194
  <p className="text-xs text-gray-500 mt-1">
131
- No {slot.type} artifacts in this board yet
195
+ No {slot.type} artifacts in this board yet.
132
196
  </p>
133
197
  )}
134
198
  </div>