@weirdfingers/baseboards 0.9.0 → 0.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weirdfingers/baseboards",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "One-command launcher for the Boards image generation application",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,7 @@ Boards Backend SDK
3
3
  Open-source creative toolkit for AI-generated content
4
4
  """
5
5
 
6
- __version__ = "0.9.0"
6
+ __version__ = "0.9.2"
7
7
 
8
8
  from .config import settings
9
9
 
@@ -16,7 +16,7 @@
16
16
  "@radix-ui/react-slot": "^1.2.3",
17
17
  "@radix-ui/react-toast": "^1.2.15",
18
18
  "@tailwindcss/postcss": "^4.1.13",
19
- "@weirdfingers/boards": "^0.9.0",
19
+ "@weirdfingers/boards": "^0.9.2",
20
20
  "class-variance-authority": "^0.7.1",
21
21
  "clsx": "^2.0.0",
22
22
  "graphql": "^16.11.0",
@@ -20,7 +20,6 @@ export default function BoardPage() {
20
20
 
21
21
  const {
22
22
  board,
23
- loading: boardLoading,
24
23
  error: boardError,
25
24
  refresh: refreshBoard,
26
25
  updateBoard,
@@ -182,7 +181,7 @@ export default function BoardPage() {
182
181
  }
183
182
 
184
183
  // Handle loading state
185
- if (boardLoading || !board) {
184
+ if (!board) {
186
185
  return (
187
186
  <div className="flex items-center justify-center min-h-screen">
188
187
  <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
@@ -320,6 +319,7 @@ export default function BoardPage() {
320
319
  <div className="mb-8">
321
320
  <GenerationGrid
322
321
  generations={generations}
322
+ boardId={boardId}
323
323
  onGenerationClick={() => {
324
324
  // TODO: Open generation detail modal
325
325
  }}
@@ -140,6 +140,7 @@ function AncestryTree({ node, currentGenerationId }: AncestryTreeProps) {
140
140
  </p>
141
141
  </div>
142
142
  {node.generation.thumbnailUrl && (
143
+ // eslint-disable-next-line @next/next/no-img-element
143
144
  <img
144
145
  src={node.generation.thumbnailUrl}
145
146
  alt="Thumbnail"
@@ -208,6 +209,7 @@ function DescendantTree({ node, currentGenerationId }: DescendantTreeProps) {
208
209
  </p>
209
210
  </div>
210
211
  {node.generation.thumbnailUrl && (
212
+ // eslint-disable-next-line @next/next/no-img-element
211
213
  <img
212
214
  src={node.generation.thumbnailUrl}
213
215
  alt="Thumbnail"
@@ -65,7 +65,7 @@ export function ArtifactInputSlots({
65
65
  e.dataTransfer.dropEffect = "copy";
66
66
  setDragOverSlot(slotName);
67
67
  }
68
- } catch (err) {
68
+ } catch {
69
69
  // Ignore errors during drag over
70
70
  }
71
71
  };
@@ -14,6 +14,7 @@ import {
14
14
  RotateCcw,
15
15
  GitBranch,
16
16
  Trash2,
17
+ Film,
17
18
  } from "lucide-react";
18
19
  import Image from "next/image";
19
20
  import { useRouter } from "next/navigation";
@@ -23,6 +24,9 @@ import {
23
24
  DropdownMenuItem,
24
25
  DropdownMenuTrigger,
25
26
  DropdownMenuSeparator,
27
+ DropdownMenuSub,
28
+ DropdownMenuSubContent,
29
+ DropdownMenuSubTrigger,
26
30
  } from "@/components/ui/dropdown-menu";
27
31
 
28
32
  interface ArtifactPreviewProps {
@@ -37,6 +41,7 @@ interface ArtifactPreviewProps {
37
41
  onDownload?: () => void;
38
42
  onPreview?: () => void;
39
43
  onDelete?: () => void;
44
+ onExtractFrame?: (position: "first" | "last") => void;
40
45
  artifactId?: string;
41
46
  prompt?: string | null;
42
47
  }
@@ -53,6 +58,7 @@ export function ArtifactPreview({
53
58
  onDownload,
54
59
  onPreview,
55
60
  onDelete,
61
+ onExtractFrame,
56
62
  artifactId,
57
63
  prompt,
58
64
  }: ArtifactPreviewProps) {
@@ -406,6 +412,34 @@ export function ArtifactPreview({
406
412
  Download
407
413
  </DropdownMenuItem>
408
414
  )}
415
+ {isComplete && artifactType === "VIDEO" && onExtractFrame && (
416
+ <DropdownMenuSub>
417
+ <DropdownMenuSubTrigger className="cursor-pointer">
418
+ <Film className="w-4 h-4 mr-2" />
419
+ Extract Frame
420
+ </DropdownMenuSubTrigger>
421
+ <DropdownMenuSubContent>
422
+ <DropdownMenuItem
423
+ onClick={(e) => {
424
+ e.stopPropagation();
425
+ onExtractFrame("first");
426
+ }}
427
+ className="cursor-pointer"
428
+ >
429
+ First Frame
430
+ </DropdownMenuItem>
431
+ <DropdownMenuItem
432
+ onClick={(e) => {
433
+ e.stopPropagation();
434
+ onExtractFrame("last");
435
+ }}
436
+ className="cursor-pointer"
437
+ >
438
+ Last Frame
439
+ </DropdownMenuItem>
440
+ </DropdownMenuSubContent>
441
+ </DropdownMenuSub>
442
+ )}
409
443
  {onDelete && (
410
444
  <>
411
445
  <DropdownMenuSeparator />
@@ -1,5 +1,10 @@
1
- import { useState } from "react";
2
- import { useGeneratorSelection, useGeneration } from "@weirdfingers/boards";
1
+ import { useState, useCallback } from "react";
2
+ import {
3
+ useGeneratorSelection,
4
+ useGeneration,
5
+ useMultiUpload,
6
+ ArtifactType,
7
+ } from "@weirdfingers/boards";
3
8
  import { ArtifactPreview } from "./ArtifactPreview";
4
9
  import { useToast } from "@/components/ui/use-toast";
5
10
  import {
@@ -25,29 +30,118 @@ interface Generation {
25
30
 
26
31
  interface GenerationGridProps {
27
32
  generations: Generation[];
33
+ boardId: string;
28
34
  onGenerationClick?: (generation: Generation) => void;
29
35
  onRemoveSuccess?: () => void;
30
36
  }
31
37
 
32
38
  export function GenerationGrid({
33
39
  generations,
40
+ boardId,
34
41
  onGenerationClick,
35
42
  onRemoveSuccess: onRemoveSuccess,
36
43
  }: GenerationGridProps) {
37
44
  const { canArtifactBeAdded, addArtifactToSlot } = useGeneratorSelection();
38
45
  const { deleteGeneration } = useGeneration();
46
+ const { uploadMultiple } = useMultiUpload();
39
47
  const { toast } = useToast();
40
48
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
41
49
  const [generationToDelete, setGenerationToDelete] =
42
50
  useState<Generation | null>(null);
51
+ const [isExtractingFrame, setIsExtractingFrame] = useState(false);
43
52
 
44
- if (generations.length === 0) {
45
- return (
46
- <div className="flex items-center justify-center py-12 text-muted-foreground">
47
- <p>No generations yet. Create your first one below!</p>
48
- </div>
49
- );
50
- }
53
+ const handleExtractFrame = useCallback(
54
+ async (generation: Generation, position: "first" | "last") => {
55
+ if (!generation.storageUrl || isExtractingFrame) return;
56
+
57
+ setIsExtractingFrame(true);
58
+
59
+ try {
60
+ // Create a video element to load the video
61
+ const video = document.createElement("video");
62
+ video.crossOrigin = "anonymous";
63
+ video.preload = "metadata";
64
+
65
+ // Wait for video metadata to load
66
+ await new Promise<void>((resolve, reject) => {
67
+ video.onloadedmetadata = () => resolve();
68
+ video.onerror = () => reject(new Error("Failed to load video"));
69
+ video.src = generation.storageUrl!;
70
+ });
71
+
72
+ // Seek to the desired position
73
+ const targetTime = position === "first" ? 0 : video.duration - 0.1;
74
+ video.currentTime = Math.max(0, targetTime);
75
+
76
+ // Wait for the video to seek to the frame
77
+ await new Promise<void>((resolve, reject) => {
78
+ video.onseeked = () => resolve();
79
+ video.onerror = () => reject(new Error("Failed to seek video"));
80
+ });
81
+
82
+ // Create a canvas and draw the video frame
83
+ const canvas = document.createElement("canvas");
84
+ canvas.width = video.videoWidth;
85
+ canvas.height = video.videoHeight;
86
+ const ctx = canvas.getContext("2d");
87
+
88
+ if (!ctx) {
89
+ throw new Error("Failed to get canvas context");
90
+ }
91
+
92
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
93
+
94
+ // Convert canvas to blob
95
+ const blob = await new Promise<Blob>((resolve, reject) => {
96
+ canvas.toBlob(
97
+ (blob) => {
98
+ if (blob) {
99
+ resolve(blob);
100
+ } else {
101
+ reject(new Error("Failed to create image blob"));
102
+ }
103
+ },
104
+ "image/png",
105
+ 1.0
106
+ );
107
+ });
108
+
109
+ // Create a File object from the blob
110
+ const fileName = `${position}-frame-${generation.id}.png`;
111
+ const file = new File([blob], fileName, { type: "image/png" });
112
+
113
+ // Upload the extracted frame as a new image generation
114
+ const results = await uploadMultiple([
115
+ {
116
+ boardId,
117
+ artifactType: ArtifactType.IMAGE,
118
+ source: file,
119
+ userDescription: `${position === "first" ? "First" : "Last"} frame extracted from video`,
120
+ parentGenerationId: generation.id,
121
+ },
122
+ ]);
123
+
124
+ if (results.length > 0) {
125
+ toast({
126
+ title: "Frame extracted",
127
+ description: `${position === "first" ? "First" : "Last"} frame has been saved as a new image.`,
128
+ });
129
+ onRemoveSuccess?.(); // Refresh the grid to show the new generation
130
+ }
131
+ } catch (error) {
132
+ console.error("Failed to extract frame:", error);
133
+ toast({
134
+ title: "Failed to extract frame",
135
+ description:
136
+ error instanceof Error ? error.message : "An unknown error occurred",
137
+ variant: "destructive",
138
+ });
139
+ } finally {
140
+ setIsExtractingFrame(false);
141
+ }
142
+ },
143
+ [boardId, uploadMultiple, toast, onRemoveSuccess, isExtractingFrame]
144
+ );
51
145
 
52
146
  const handleDownload = async (generation: Generation) => {
53
147
  if (!generation.storageUrl) return;
@@ -128,6 +222,14 @@ export function GenerationGrid({
128
222
  }
129
223
  };
130
224
 
225
+ if (generations.length === 0) {
226
+ return (
227
+ <div className="flex items-center justify-center py-12 text-muted-foreground">
228
+ <p>No generations yet. Create your first one below!</p>
229
+ </div>
230
+ );
231
+ }
232
+
131
233
  return (
132
234
  <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
133
235
  {generations.map((generation) => {
@@ -150,6 +252,7 @@ export function GenerationGrid({
150
252
  onDownload={() => handleDownload(generation)}
151
253
  onPreview={() => handlePreview(generation)}
152
254
  onDelete={() => handleDeleteClick(generation)}
255
+ onExtractFrame={(position) => handleExtractFrame(generation, position)}
153
256
  />
154
257
  );
155
258
  })}
@@ -7,6 +7,7 @@ import {
7
7
  useGeneratorSelection,
8
8
  } from "@weirdfingers/boards";
9
9
  import { GeneratorSelector, GeneratorInfo } from "./GeneratorSelector";
10
+ import { useGeneratorMRU } from "@/hooks/useGeneratorMRU";
10
11
  import { ArtifactInputSlots } from "./ArtifactInputSlots";
11
12
 
12
13
  interface Generation {
@@ -42,6 +43,8 @@ export function GenerationInput({
42
43
  setSelectedArtifacts
43
44
  } = useGeneratorSelection();
44
45
 
46
+ const { lastUsedGenerator } = useGeneratorMRU();
47
+
45
48
  const [prompt, setPrompt] = useState("");
46
49
  const [attachedImage, setAttachedImage] = useState<Generation | null>(null);
47
50
  const [showSettings, setShowSettings] = useState(false);
@@ -49,10 +52,21 @@ export function GenerationInput({
49
52
 
50
53
  // Initialize selected generator if not set
51
54
  useEffect(() => {
52
- if (!selectedGenerator && generators.length > 0) {
53
- setSelectedGenerator(generators[0]);
55
+ if (selectedGenerator || generators.length === 0) {
56
+ return;
57
+ }
58
+
59
+ // Try to load from MRU
60
+ if (lastUsedGenerator) {
61
+ const match = generators.find(g => g.name === lastUsedGenerator);
62
+ if (match) {
63
+ setSelectedGenerator(match);
64
+ return;
65
+ }
54
66
  }
55
- }, [generators, selectedGenerator, setSelectedGenerator]);
67
+
68
+ setSelectedGenerator(generators[0]);
69
+ }, [generators, selectedGenerator, setSelectedGenerator, lastUsedGenerator]);
56
70
 
57
71
  const artifactSlots = useMemo(() => {
58
72
  if (!parsedSchema) return [];
@@ -1,15 +1,16 @@
1
1
  "use client";
2
2
 
3
- import { Zap, Check, Search } from "lucide-react";
3
+ import { useState, useMemo, useEffect, useDeferredValue, useRef } from "react";
4
4
  import type { JSONSchema7 } from "@weirdfingers/boards";
5
5
  import { useGeneratorSelection } from "@weirdfingers/boards";
6
+ import { Zap, Search, Check } from "lucide-react";
6
7
  import {
7
8
  DropdownMenu,
8
9
  DropdownMenuContent,
9
10
  DropdownMenuItem,
10
11
  DropdownMenuTrigger,
11
12
  } from "@/components/ui/dropdown-menu";
12
- import { useState, useMemo, useEffect, useDeferredValue, useRef } from "react";
13
+ import { useGeneratorMRU } from "@/hooks/useGeneratorMRU";
13
14
 
14
15
  export interface GeneratorInfo {
15
16
  name: string;
@@ -24,37 +25,20 @@ interface GeneratorSelectorProps {
24
25
  onSelect: (generator: GeneratorInfo) => void;
25
26
  }
26
27
 
27
- const MRU_STORAGE_KEY = "boards-generator-mru";
28
- const MRU_MAX_SIZE = 3;
29
-
30
28
  export function GeneratorSelector({
31
29
  generators,
32
30
  selectedGenerator,
33
31
  onSelect,
34
32
  }: GeneratorSelectorProps) {
35
33
  const { setSelectedGenerator } = useGeneratorSelection();
34
+ const { mruGenerators, addGeneratorToMRU } = useGeneratorMRU();
35
+
36
36
  const [searchInput, setSearchInput] = useState("");
37
37
  const deferredSearch = useDeferredValue(searchInput);
38
38
  const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
39
- const [mruGenerators, setMruGenerators] = useState<string[]>([]);
40
39
  const [isOpen, setIsOpen] = useState(false);
41
40
  const searchInputRef = useRef<HTMLInputElement>(null);
42
41
 
43
- // Load MRU from localStorage on mount
44
- useEffect(() => {
45
- try {
46
- const stored = localStorage.getItem(MRU_STORAGE_KEY);
47
- if (stored) {
48
- const parsed = JSON.parse(stored);
49
- if (Array.isArray(parsed)) {
50
- setMruGenerators(parsed);
51
- }
52
- }
53
- } catch (error) {
54
- console.error("Failed to load MRU generators:", error);
55
- }
56
- }, []);
57
-
58
42
  // Focus search input when dropdown opens
59
43
  useEffect(() => {
60
44
  if (isOpen) {
@@ -123,7 +107,7 @@ export function GeneratorSelector({
123
107
 
124
108
  const getGeneratorIcon = (name: string) => {
125
109
  // You can customize icons per generator here
126
- return <Zap className="w-4 h-4" />;
110
+ return <Zap className="w-4 h-4" aria-label={name} />;
127
111
  };
128
112
 
129
113
  const handleSelect = (generator: GeneratorInfo) => {
@@ -132,17 +116,7 @@ export function GeneratorSelector({
132
116
  onSelect(generator);
133
117
 
134
118
  // Update MRU
135
- const newMru = [
136
- generator.name,
137
- ...mruGenerators.filter((name) => name !== generator.name),
138
- ].slice(0, MRU_MAX_SIZE);
139
-
140
- setMruGenerators(newMru);
141
- try {
142
- localStorage.setItem(MRU_STORAGE_KEY, JSON.stringify(newMru));
143
- } catch (error) {
144
- console.error("Failed to save MRU generators:", error);
145
- }
119
+ addGeneratorToMRU(generator.name);
146
120
  };
147
121
 
148
122
  const toggleType = (type: string) => {
@@ -12,6 +12,7 @@ type ToasterToast = ToastProps & {
12
12
  action?: ToastActionElement
13
13
  }
14
14
 
15
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
15
16
  const actionTypes = {
16
17
  ADD_TOAST: "ADD_TOAST",
17
18
  UPDATE_TOAST: "UPDATE_TOAST",
@@ -0,0 +1,57 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+
3
+ const MRU_STORAGE_KEY = "boards-generator-mru";
4
+ const MRU_MAX_SIZE = 3;
5
+
6
+ export function useGeneratorMRU() {
7
+ const [mruGenerators, setMruGenerators] = useState<string[]>([]);
8
+
9
+ // Load MRU from localStorage on mount
10
+ useEffect(() => {
11
+ try {
12
+ if (typeof window === "undefined") return;
13
+
14
+ const stored = localStorage.getItem(MRU_STORAGE_KEY);
15
+ if (stored) {
16
+ const parsed = JSON.parse(stored);
17
+ // Robustness check: Ensure it's an array of strings
18
+ if (
19
+ Array.isArray(parsed) &&
20
+ parsed.every((item) => typeof item === "string")
21
+ ) {
22
+ setMruGenerators(parsed);
23
+ }
24
+ }
25
+ } catch (error) {
26
+ console.error("Failed to load MRU generators:", error);
27
+ // Fallback to empty list is implicit via initial state
28
+ }
29
+ }, []);
30
+
31
+ const addGeneratorToMRU = useCallback((generatorName: string) => {
32
+ setMruGenerators((prev) => {
33
+ // Create new list with generator at the front, removing duplicates
34
+ const newMru = [
35
+ generatorName,
36
+ ...prev.filter((name) => name !== generatorName),
37
+ ].slice(0, MRU_MAX_SIZE);
38
+
39
+ // Persist to localStorage
40
+ try {
41
+ if (typeof window !== "undefined") {
42
+ localStorage.setItem(MRU_STORAGE_KEY, JSON.stringify(newMru));
43
+ }
44
+ } catch (error) {
45
+ console.error("Failed to save MRU generators:", error);
46
+ }
47
+
48
+ return newMru;
49
+ });
50
+ }, []);
51
+
52
+ return {
53
+ mruGenerators,
54
+ lastUsedGenerator: mruGenerators.length > 0 ? mruGenerators[0] : undefined,
55
+ addGeneratorToMRU,
56
+ };
57
+ }