import { BaseBoxShapeUtil, Geometry2d, HTMLContainer, Rectangle2d, TLBaseShape, } from "tldraw" import React, { useState } from "react" import { getFalProxyConfig, getWorkerApiUrl } from "@/lib/clientConfig" import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper" import { usePinnedToView } from "@/hooks/usePinnedToView" import { useMaximize } from "@/hooks/useMaximize" // Feature flag: Set to false when fal.ai API is ready for production const USE_MOCK_API = false // fal.ai model to use for image generation const FAL_IMAGE_MODEL = "fal-ai/flux/dev" // Type definition for fal.ai API responses interface FalImageResponse { images?: Array<{ url: string; width?: number; height?: number; content_type?: string }> error?: string detail?: string } // Individual image entry in the history interface GeneratedImage { id: string prompt: string imageUrl: string timestamp: number } type IImageGen = TLBaseShape< "ImageGen", { w: number h: number prompt: string imageHistory: GeneratedImage[] // Thread of all generated images (newest first) isLoading: boolean loadingPrompt: string | null // The prompt currently being generated error: string | null endpointId?: string // Optional custom endpoint ID tags: string[] pinnedToView: boolean } > export class ImageGenShape extends BaseBoxShapeUtil { static override type = "ImageGen" as const // Image generation theme color: Blue static readonly PRIMARY_COLOR = "#007AFF" MIN_WIDTH = 300 as const MIN_HEIGHT = 300 as const DEFAULT_WIDTH = 400 as const DEFAULT_HEIGHT = 400 as const getDefaultProps(): IImageGen["props"] { return { w: this.DEFAULT_WIDTH, h: this.DEFAULT_HEIGHT, prompt: "", imageHistory: [], isLoading: false, loadingPrompt: null, error: null, tags: ['image', 'ai-generated'], pinnedToView: false, } } getGeometry(shape: IImageGen): Geometry2d { // Ensure minimum dimensions for proper hit testing return new Rectangle2d({ width: Math.max(shape.props.w, 1), height: Math.max(shape.props.h, 1), isFilled: true, }) } component(shape: IImageGen) { // Capture editor reference to avoid stale 'this' during drag operations const editor = this.editor const isSelected = editor.getSelectedShapeIds().includes(shape.id) // Pin to view functionality usePinnedToView(editor, shape.id, shape.props.pinnedToView) // Use the maximize hook for fullscreen functionality const { isMaximized, toggleMaximize } = useMaximize({ editor: editor, shapeId: shape.id, currentW: shape.props.w, currentH: shape.props.h, shapeType: 'ImageGen', }) const handlePinToggle = () => { editor.updateShape({ id: shape.id, type: "ImageGen", props: { pinnedToView: !shape.props.pinnedToView }, }) } const generateImage = async (prompt: string) => { // Store the prompt being used and clear any previous errors editor.updateShape({ id: shape.id, type: "ImageGen", props: { error: null, isLoading: true, loadingPrompt: prompt }, }) try { // Mock API mode: Return placeholder image for testing if (USE_MOCK_API) { await new Promise(resolve => setTimeout(resolve, 1500)) const mockImageUrl = `https://via.placeholder.com/512x512/4F46E5/FFFFFF?text=${encodeURIComponent(prompt.substring(0, 30))}` const currentShape = editor.getShape(shape.id) const currentHistory = currentShape?.props.imageHistory || [] const newImage: GeneratedImage = { id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, prompt: prompt, imageUrl: mockImageUrl, timestamp: Date.now() } editor.updateShape({ id: shape.id, type: "ImageGen", props: { imageHistory: [newImage, ...currentHistory], isLoading: false, loadingPrompt: null, error: null }, }) return } // Real API mode: Use fal.ai via worker proxy // fal.ai is faster and more reliable than RunPod for image generation const workerUrl = getWorkerApiUrl() const url = `${workerUrl}/api/fal/subscribe/${FAL_IMAGE_MODEL}` const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: prompt, image_size: "square_hd", num_inference_steps: 28, guidance_scale: 3.5, num_images: 1, enable_safety_checker: true }) }) if (!response.ok) { const errorText = await response.text() console.error("❌ ImageGen: Error response:", errorText) throw new Error(`HTTP error! status: ${response.status} - ${errorText}`) } const data = await response.json() as FalImageResponse // fal.ai returns { images: [{ url: "..." }] } if (data.images && data.images.length > 0) { const imageUrl = data.images[0].url const currentShape = editor.getShape(shape.id) const currentHistory = currentShape?.props.imageHistory || [] const newImage: GeneratedImage = { id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, prompt: prompt, imageUrl: imageUrl, timestamp: Date.now() } editor.updateShape({ id: shape.id, type: "ImageGen", props: { imageHistory: [newImage, ...currentHistory], isLoading: false, loadingPrompt: null, error: null }, }) } else if (data.error || data.detail) { throw new Error(`fal.ai API error: ${data.error || data.detail}`) } else { console.error("❌ ImageGen: Unexpected response structure:", JSON.stringify(data, null, 2)) throw new Error("No images returned from fal.ai API") } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) console.error("❌ ImageGen: Error:", errorMessage) let userFriendlyError = '' if (errorMessage.includes('FAL_API_KEY not configured')) { userFriendlyError = '❌ fal.ai API key not configured on server.' } else if (errorMessage.includes('401') || errorMessage.includes('403') || errorMessage.includes('Unauthorized')) { userFriendlyError = '❌ API key authentication failed.' } else if (errorMessage.includes('404')) { userFriendlyError = '❌ API endpoint not found.' } else if (errorMessage.includes('No images returned')) { userFriendlyError = '❌ Image generation completed but no image was returned.' } else { // Truncate very long error messages for UI display const maxLength = 500 if (errorMessage.length > maxLength) { userFriendlyError = `❌ Error: ${errorMessage.substring(0, maxLength)}...\n\n(Full error in console)` } else { userFriendlyError = `❌ Error: ${errorMessage}` } } editor.updateShape({ id: shape.id, type: "ImageGen", props: { isLoading: false, loadingPrompt: null, error: userFriendlyError }, }) } } const handleGenerate = () => { if (shape.props.prompt.trim() && !shape.props.isLoading) { generateImage(shape.props.prompt) editor.updateShape({ id: shape.id, type: "ImageGen", props: { prompt: "" }, }) } } const [isMinimized, setIsMinimized] = useState(false) const handleClose = () => { editor.deleteShape(shape.id) } const handleMinimize = () => { setIsMinimized(!isMinimized) } const handleTagsChange = (newTags: string[]) => { editor.updateShape({ id: shape.id, type: "ImageGen", props: { tags: newTags }, }) } return ( 🎨 Image Generator Generating... ) : undefined } >
{/* Image Thread - scrollable history of generated images */}
{/* Loading State - shown at top when generating */} {shape.props.isLoading && (
Generating image...
{shape.props.loadingPrompt && (
Prompt: {shape.props.loadingPrompt}
)}
)} {/* Image History - each image as a card */} {shape.props.imageHistory.map((image, index) => (
{/* Image */}
{image.prompt} { console.error("❌ ImageGen: Failed to load image:", image.imageUrl) // Remove this image from history const newHistory = shape.props.imageHistory.filter(img => img.id !== image.id) editor.updateShape({ id: shape.id, type: "ImageGen", props: { imageHistory: newHistory }, }) }} />
{/* Prompt and action buttons */}
Prompt: {image.prompt}
{/* Delete button for history items */}
))} {/* Empty State */} {shape.props.imageHistory.length === 0 && !shape.props.isLoading && !shape.props.error && (
Generated images will appear here
)}
{/* Input Section - Mobile Optimized */}