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:
parent
1b67a2fe7f
commit
0677ad3b5d
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue