feat: add BlenderGen shape for 3D Blender rendering

Add custom tldraw shape and tool for generating 3D renders via Blender:
- BlenderGenShapeUtil.tsx: custom shape with preset selector and controls
- BlenderGenTool.ts: toolbar tool for creating Blender render shapes
- Worker routes for /api/blender/render and /api/blender/status/:jobId
- Proxies requests to Netcup-hosted Blender render server

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-01 07:45:03 +01:00
parent 1b67a2fe7f
commit 0677ad3b5d
5 changed files with 800 additions and 0 deletions

View File

@ -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,

View File

@ -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<IBlenderGen> {
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<IBlenderGen>({
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<IBlenderGen>({
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<IBlenderGen>(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<IBlenderGen>({
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<IBlenderGen>({
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<IBlenderGen>({
id: shape.id,
type: "BlenderGen",
props: { tags: newTags },
})
}
return (
<HTMLContainer id={shape.id}>
<StandardizedToolWrapper
title="🎬 Blender 3D"
primaryColor={BlenderGenShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
onMaximize={toggleMaximize}
isMaximized={isMaximized}
editor={editor}
shapeId={shape.id}
tags={shape.props.tags || []}
onTagsChange={handleTagsChange}
tagsEditable={true}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
headerContent={
shape.props.isLoading ? (
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
🎬 Blender 3D
<span style={{
marginLeft: 'auto',
fontSize: '11px',
color: BlenderGenShape.PRIMARY_COLOR,
animation: 'pulse 1.5s ease-in-out infinite'
}}>
Rendering...
</span>
</span>
) : undefined
}
>
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
padding: '12px',
gap: '12px',
overflow: 'auto',
backgroundColor: '#1a1a1a'
}}>
{/* Preset Selection */}
<div style={{
display: 'flex',
gap: '6px',
flexWrap: 'wrap',
}}>
{presets.map((preset) => (
<button
key={preset.id}
onClick={(e) => {
e.stopPropagation()
editor.updateShape<IBlenderGen>({
id: shape.id,
type: "BlenderGen",
props: { preset: preset.id },
})
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: '1 1 auto',
minWidth: '70px',
padding: '8px 10px',
backgroundColor: shape.props.preset === preset.id ? BlenderGenShape.PRIMARY_COLOR : '#2a2a2a',
border: shape.props.preset === preset.id ? `2px solid ${BlenderGenShape.PRIMARY_COLOR}` : '2px solid #3a3a3a',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: 500,
color: shape.props.preset === preset.id ? '#fff' : '#aaa',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2px',
transition: 'all 0.15s',
}}
title={preset.description}
>
<span style={{ fontSize: '16px' }}>{preset.icon}</span>
<span>{preset.label}</span>
</button>
))}
</div>
{/* Text3D Input (shown only for text3d preset) */}
{shape.props.preset === 'text3d' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<label style={{ fontSize: '11px', color: '#888', fontWeight: 500 }}>
3D Text Content
</label>
<input
type="text"
style={{
backgroundColor: '#2a2a2a',
border: '1px solid #3a3a3a',
borderRadius: '6px',
padding: '10px 12px',
fontSize: '14px',
color: '#fff',
fontFamily: 'inherit',
}}
placeholder="Enter text to render in 3D..."
value={shape.props.text3dContent}
onChange={(e) => {
editor.updateShape<IBlenderGen>({
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()
}
}}
/>
</div>
)}
{/* Complexity Slider */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label style={{ fontSize: '11px', color: '#888', fontWeight: 500 }}>
Complexity
</label>
<span style={{ fontSize: '11px', color: '#666' }}>
{shape.props.complexity}/10
</span>
</div>
<input
type="range"
min="1"
max="10"
value={shape.props.complexity}
onChange={(e) => {
editor.updateShape<IBlenderGen>({
id: shape.id,
type: "BlenderGen",
props: { complexity: parseInt(e.target.value) },
})
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '100%',
accentColor: BlenderGenShape.PRIMARY_COLOR,
}}
/>
</div>
{/* Render Button */}
<button
onClick={(e) => {
e.stopPropagation()
handleGenerate()
}}
onPointerDown={(e) => e.stopPropagation()}
disabled={shape.props.isLoading}
style={{
padding: '12px 20px',
backgroundColor: shape.props.isLoading ? '#444' : BlenderGenShape.PRIMARY_COLOR,
border: 'none',
borderRadius: '8px',
cursor: shape.props.isLoading ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 600,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'all 0.15s',
opacity: shape.props.isLoading ? 0.7 : 1,
}}
>
{shape.props.isLoading ? (
<>
<div
style={{
width: 16,
height: 16,
border: "2px solid rgba(255,255,255,0.3)",
borderTop: "2px solid #fff",
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
Rendering...
</>
) : (
<>
<span>🎬</span>
Render 3D Scene
</>
)}
</button>
{/* Render History */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '12px',
overflow: 'auto',
minHeight: 0,
}}>
{/* Loading State */}
{shape.props.isLoading && (
<div style={{
display: "flex",
flexDirection: "column",
backgroundColor: "#2a2a2a",
borderRadius: "6px",
border: '1px solid #3a3a3a',
overflow: 'hidden',
}}>
<div style={{
padding: '24px',
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 12,
}}>
<div
style={{
width: 40,
height: 40,
border: "4px solid #3a3a3a",
borderTop: `4px solid ${BlenderGenShape.PRIMARY_COLOR}`,
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
<span style={{ color: "#aaa", fontSize: "14px" }}>
Rendering 3D scene...
</span>
<span style={{ color: "#666", fontSize: "11px" }}>
This may take 30-60 seconds
</span>
</div>
{shape.props.loadingPrompt && (
<div style={{
borderTop: '1px solid #3a3a3a',
padding: '8px 10px',
backgroundColor: '#222',
fontSize: '11px',
color: '#888',
}}>
<span style={{ fontWeight: 500, color: '#666' }}>Preset: </span>
{shape.props.preset}
{shape.props.preset === 'text3d' && ` - "${shape.props.loadingPrompt}"`}
</div>
)}
</div>
)}
{/* Render History */}
{shape.props.renderHistory.map((render, index) => (
<div
key={render.id}
style={{
display: "flex",
flexDirection: "column",
backgroundColor: "#2a2a2a",
borderRadius: "6px",
overflow: "hidden",
border: index === 0 && !shape.props.isLoading ? `2px solid ${BlenderGenShape.PRIMARY_COLOR}` : '1px solid #3a3a3a',
}}
>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
maxHeight: index === 0 ? '250px' : '120px',
backgroundColor: '#1a1a1a',
}}>
<img
src={render.imageUrl}
alt={render.prompt}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
}}
onError={() => {
const newHistory = shape.props.renderHistory.filter(r => r.id !== render.id)
editor.updateShape<IBlenderGen>({
id: shape.id,
type: "BlenderGen",
props: { renderHistory: newHistory },
})
}}
/>
</div>
<div style={{
borderTop: '1px solid #3a3a3a',
padding: '8px 10px',
backgroundColor: '#222',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}>
<div style={{
fontSize: '11px',
color: '#888',
display: 'flex',
gap: '12px',
}}>
<span><strong>Preset:</strong> {render.preset}</span>
{render.seed && <span><strong>Seed:</strong> {render.seed}</span>}
{render.renderTime && <span><strong>Time:</strong> {render.renderTime}s</span>}
</div>
<div style={{ display: 'flex', gap: '6px' }}>
<button
onClick={(e) => {
e.stopPropagation()
const link = document.createElement('a')
link.href = render.imageUrl
link.download = `blender-${render.preset}-${render.timestamp}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '6px 10px',
backgroundColor: BlenderGenShape.PRIMARY_COLOR,
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: 500,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
}}
>
<span></span> Download
</button>
<button
onClick={(e) => {
e.stopPropagation()
const newHistory = shape.props.renderHistory.filter(r => r.id !== render.id)
editor.updateShape<IBlenderGen>({
id: shape.id,
type: "BlenderGen",
props: { renderHistory: newHistory },
})
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
padding: '6px 10px',
backgroundColor: '#333',
border: '1px solid #444',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '11px',
color: '#999',
}}
title="Remove from history"
>
🗑
</button>
</div>
</div>
</div>
))}
{/* Empty State */}
{shape.props.renderHistory.length === 0 && !shape.props.isLoading && !shape.props.error && (
<div style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#2a2a2a",
borderRadius: "6px",
color: "#666",
fontSize: "13px",
border: '1px solid #3a3a3a',
minHeight: '120px',
gap: '8px',
}}>
<span style={{ fontSize: '32px' }}>🎬</span>
<span>Select a preset and click Render</span>
</div>
)}
</div>
{/* Error Display */}
{shape.props.error && (
<div style={{
padding: "8px 12px",
backgroundColor: "#3a2020",
border: "1px solid #5a3030",
borderRadius: "6px",
color: "#f88",
fontSize: "12px",
display: "flex",
alignItems: "flex-start",
gap: "8px",
}}>
<span style={{ fontSize: "14px" }}></span>
<span style={{ flex: 1 }}>{shape.props.error}</span>
<button
onClick={() => {
editor.updateShape<IBlenderGen>({
id: shape.id,
type: "BlenderGen",
props: { error: null },
})
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
padding: "2px 6px",
backgroundColor: "#5a3030",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "10px",
color: "#f88",
}}
>
</button>
</div>
)}
</div>
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`}</style>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
override indicator(shape: IBlenderGen) {
return (
<rect
width={shape.props.w}
height={shape.props.h}
rx={6}
/>
)
}
}

View File

@ -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')
}
}

View File

@ -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

View File

@ -1439,6 +1439,95 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
}
})
// =============================================================================
// 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
*/