diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 9c1a332..bc2c8ac 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -47,6 +47,9 @@ import { ImageGenShape } from "@/shapes/ImageGenShapeUtil" import { ImageGenTool } from "@/tools/ImageGenTool" import { VideoGenShape } from "@/shapes/VideoGenShapeUtil" import { VideoGenTool } from "@/tools/VideoGenTool" +// Blender 3D generation +import { BlenderGenShape } from "@/shapes/BlenderGenShapeUtil" +import { BlenderGenTool } from "@/tools/BlenderGenTool" // Drawfast - dev only import { DrawfastShape } from "@/shapes/DrawfastShapeUtil" import { DrawfastTool } from "@/tools/DrawfastTool" @@ -176,6 +179,7 @@ const customShapeUtils = [ FathomNoteShape, // Individual Fathom meeting notes created from FathomMeetingsBrowser ImageGenShape, VideoGenShape, + BlenderGenShape, // Blender 3D procedural generation ...(ENABLE_DRAWFAST ? [DrawfastShape] : []), // Drawfast - dev only MultmuxShape, MycelialIntelligenceShape, // AI-powered collaborative intelligence shape @@ -202,6 +206,7 @@ const customTools = [ FathomMeetingsTool, ImageGenTool, VideoGenTool, + BlenderGenTool, // Blender 3D procedural generation ...(ENABLE_DRAWFAST ? [DrawfastTool] : []), // Drawfast - dev only MultmuxTool, PrivateWorkspaceTool, diff --git a/src/shapes/BlenderGenShapeUtil.tsx b/src/shapes/BlenderGenShapeUtil.tsx new file mode 100644 index 0000000..57ecc87 --- /dev/null +++ b/src/shapes/BlenderGenShapeUtil.tsx @@ -0,0 +1,693 @@ +import { + BaseBoxShapeUtil, + Geometry2d, + HTMLContainer, + Rectangle2d, + TLBaseShape, +} from "tldraw" +import React, { useState } from "react" +import { getWorkerApiUrl } from "@/lib/clientConfig" +import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper" +import { usePinnedToView } from "@/hooks/usePinnedToView" +import { useMaximize } from "@/hooks/useMaximize" + +// Blender render presets +type BlenderPreset = 'abstract' | 'geometric' | 'landscape' | 'text3d' | 'particles' + +// Individual render entry in the history +interface GeneratedRender { + id: string + prompt: string + preset: BlenderPreset + imageUrl: string + timestamp: number + renderTime?: number + seed?: number +} + +type IBlenderGen = TLBaseShape< + "BlenderGen", + { + w: number + h: number + prompt: string + preset: BlenderPreset + complexity: number // 1-10 + text3dContent: string // For text3d preset + seed: number | null // For reproducibility + renderHistory: GeneratedRender[] + isLoading: boolean + loadingPrompt: string | null + progress: number // 0-100 + error: string | null + tags: string[] + pinnedToView: boolean + } +> + +export class BlenderGenShape extends BaseBoxShapeUtil { + static override type = "BlenderGen" as const + + // Blender theme color: Orange/3D + static readonly PRIMARY_COLOR = "#E87D0D" + + MIN_WIDTH = 320 as const + MIN_HEIGHT = 400 as const + DEFAULT_WIDTH = 420 as const + DEFAULT_HEIGHT = 500 as const + + getDefaultProps(): IBlenderGen["props"] { + return { + w: this.DEFAULT_WIDTH, + h: this.DEFAULT_HEIGHT, + prompt: "", + preset: "abstract", + complexity: 5, + text3dContent: "", + seed: null, + renderHistory: [], + isLoading: false, + loadingPrompt: null, + progress: 0, + error: null, + tags: ['3d', 'blender', 'render'], + pinnedToView: false, + } + } + + getGeometry(shape: IBlenderGen): Geometry2d { + return new Rectangle2d({ + width: Math.max(shape.props.w, 1), + height: Math.max(shape.props.h, 1), + isFilled: true, + }) + } + + component(shape: IBlenderGen) { + const editor = this.editor + const isSelected = editor.getSelectedShapeIds().includes(shape.id) + + usePinnedToView(editor, shape.id, shape.props.pinnedToView) + + const { isMaximized, toggleMaximize } = useMaximize({ + editor: editor, + shapeId: shape.id, + currentW: shape.props.w, + currentH: shape.props.h, + shapeType: 'BlenderGen', + }) + + const handlePinToggle = () => { + editor.updateShape({ + id: shape.id, + type: "BlenderGen", + props: { pinnedToView: !shape.props.pinnedToView }, + }) + } + + const presets: { id: BlenderPreset; label: string; icon: string; description: string }[] = [ + { id: 'abstract', label: 'Abstract', icon: '🔮', description: 'Random geometric shapes' }, + { id: 'geometric', label: 'Geometric', icon: '📐', description: 'Grid-based patterns' }, + { id: 'landscape', label: 'Landscape', icon: '🏔️', description: 'Procedural terrain' }, + { id: 'text3d', label: '3D Text', icon: 'Aa', description: 'Metallic 3D text' }, + { id: 'particles', label: 'Particles', icon: '✨', description: 'Particle effects' }, + ] + + const generateRender = async () => { + const prompt = shape.props.preset === 'text3d' + ? shape.props.text3dContent || 'BLENDER' + : shape.props.prompt || shape.props.preset + + editor.updateShape({ + id: shape.id, + type: "BlenderGen", + props: { + error: null, + isLoading: true, + loadingPrompt: prompt, + progress: 0, + }, + }) + + try { + const workerUrl = getWorkerApiUrl() + const url = `${workerUrl}/api/blender/render` + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + preset: shape.props.preset, + text: shape.props.preset === 'text3d' ? (shape.props.text3dContent || 'BLENDER') : undefined, + complexity: shape.props.complexity, + seed: shape.props.seed, + resolution: "1920x1080", + samples: 64, + }) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`HTTP error! status: ${response.status} - ${errorText}`) + } + + const data = await response.json() + + if (data.imageUrl) { + const currentShape = editor.getShape(shape.id) + const currentHistory = currentShape?.props.renderHistory || [] + + const newRender: GeneratedRender = { + id: `render-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + prompt: prompt, + preset: shape.props.preset, + imageUrl: data.imageUrl, + timestamp: Date.now(), + renderTime: data.renderTime, + seed: data.seed, + } + + editor.updateShape({ + id: shape.id, + type: "BlenderGen", + props: { + renderHistory: [newRender, ...currentHistory], + isLoading: false, + loadingPrompt: null, + progress: 100, + error: null, + }, + }) + } else if (data.error) { + throw new Error(data.error) + } else { + throw new Error("No image returned from Blender API") + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error("BlenderGen: Error:", errorMessage) + + editor.updateShape({ + id: shape.id, + type: "BlenderGen", + props: { + isLoading: false, + loadingPrompt: null, + progress: 0, + error: `Render failed: ${errorMessage}`, + }, + }) + } + } + + const handleGenerate = () => { + if (!shape.props.isLoading) { + generateRender() + } + } + + 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: "BlenderGen", + props: { tags: newTags }, + }) + } + + return ( + + + 🎬 Blender 3D + + Rendering... + + + ) : undefined + } + > +
+ {/* Preset Selection */} +
+ {presets.map((preset) => ( + + ))} +
+ + {/* Text3D Input (shown only for text3d preset) */} + {shape.props.preset === 'text3d' && ( +
+ + { + editor.updateShape({ + id: shape.id, + type: "BlenderGen", + props: { text3dContent: e.target.value }, + }) + }} + onPointerDown={(e) => e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter' && !shape.props.isLoading) { + handleGenerate() + } + }} + /> +
+ )} + + {/* Complexity Slider */} +
+
+ + + {shape.props.complexity}/10 + +
+ { + editor.updateShape({ + id: shape.id, + type: "BlenderGen", + props: { complexity: parseInt(e.target.value) }, + }) + }} + onPointerDown={(e) => e.stopPropagation()} + style={{ + width: '100%', + accentColor: BlenderGenShape.PRIMARY_COLOR, + }} + /> +
+ + {/* Render Button */} + + + {/* Render History */} +
+ {/* Loading State */} + {shape.props.isLoading && ( +
+
+
+ + Rendering 3D scene... + + + This may take 30-60 seconds + +
+ {shape.props.loadingPrompt && ( +
+ Preset: + {shape.props.preset} + {shape.props.preset === 'text3d' && ` - "${shape.props.loadingPrompt}"`} +
+ )} +
+ )} + + {/* Render History */} + {shape.props.renderHistory.map((render, index) => ( +
+
+ {render.prompt} { + const newHistory = shape.props.renderHistory.filter(r => r.id !== render.id) + editor.updateShape({ + id: shape.id, + type: "BlenderGen", + props: { renderHistory: newHistory }, + }) + }} + /> +
+
+
+ Preset: {render.preset} + {render.seed && Seed: {render.seed}} + {render.renderTime && Time: {render.renderTime}s} +
+
+ + +
+
+
+ ))} + + {/* Empty State */} + {shape.props.renderHistory.length === 0 && !shape.props.isLoading && !shape.props.error && ( +
+ 🎬 + Select a preset and click Render +
+ )} +
+ + {/* Error Display */} + {shape.props.error && ( +
+ ⚠️ + {shape.props.error} + +
+ )} +
+ + + + + ) + } + + override indicator(shape: IBlenderGen) { + return ( + + ) + } +} diff --git a/src/tools/BlenderGenTool.ts b/src/tools/BlenderGenTool.ts new file mode 100644 index 0000000..7e5a954 --- /dev/null +++ b/src/tools/BlenderGenTool.ts @@ -0,0 +1,11 @@ +import { BaseBoxShapeTool, TLEventHandlers } from 'tldraw' + +export class BlenderGenTool extends BaseBoxShapeTool { + static override id = 'BlenderGen' + static override initial = 'idle' + override shapeType = 'BlenderGen' + + override onComplete: TLEventHandlers["onComplete"] = () => { + this.editor.setCurrentTool('select') + } +} diff --git a/worker/types.ts b/worker/types.ts index eadd745..1fe028a 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -23,6 +23,8 @@ export interface Environment { RUNPOD_VIDEO_ENDPOINT_ID?: string; RUNPOD_TEXT_ENDPOINT_ID?: string; RUNPOD_WHISPER_ENDPOINT_ID?: string; + // Blender render server URL + BLENDER_API_URL?: string; } // CryptID types for auth diff --git a/worker/worker.ts b/worker/worker.ts index c96a27c..2aef82e 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -1439,6 +1439,95 @@ const router = AutoRouter({ } }) + // ============================================================================= + // Blender 3D Render API + // Proxies render requests to blender-automation server on Netcup RS 8000 + // ============================================================================= + + .post("/api/blender/render", async (req, env) => { + // Blender render server URL - hosted on Netcup RS 8000 + const BLENDER_API_URL = env.BLENDER_API_URL || 'https://blender.jeffemmett.com' + + try { + const body = await req.json() as { + preset: string + text?: string + complexity?: number + seed?: number + resolution?: string + samples?: number + } + + console.log('Blender render request:', body) + + const response = await fetch(`${BLENDER_API_URL}/render`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('Blender API error:', response.status, errorText) + return new Response(JSON.stringify({ + error: `Blender API error: ${response.status}`, + details: errorText + }), { + status: response.status, + headers: { 'Content-Type': 'application/json' } + }) + } + + const data = await response.json() + return new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + console.error('Blender render proxy error:', error) + return new Response(JSON.stringify({ + error: 'Blender render failed', + details: (error as Error).message + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }) + } + }) + + // Get render job status + .get("/api/blender/status/:jobId", async (req, env) => { + const BLENDER_API_URL = env.BLENDER_API_URL || 'https://blender.jeffemmett.com' + const { jobId } = req.params + + try { + const response = await fetch(`${BLENDER_API_URL}/status/${jobId}`) + + if (!response.ok) { + const errorText = await response.text() + return new Response(JSON.stringify({ + error: `Blender status error: ${response.status}`, + details: errorText + }), { + status: response.status, + headers: { 'Content-Type': 'application/json' } + }) + } + + const data = await response.json() + return new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + console.error('Blender status proxy error:', error) + return new Response(JSON.stringify({ error: (error as Error).message }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }) + } + }) + /** * Compute SHA-256 hash of content for change detection */