Spaces:
Running
Running
// @deno-types="npm:@types/node" | |
import { XMLParser } from "npm:[email protected]"; | |
import { serve } from "https://deno.land/[email protected]/http/server.ts"; | |
import { createClient } from "https://esm.sh/@supabase/[email protected]"; | |
// Deno environment | |
declare const Deno: { | |
env: { | |
get(key: string): string | undefined; | |
}; | |
}; | |
// OpenAI client | |
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY"); | |
if (!OPENAI_API_KEY) { | |
console.error("Missing OPENAI_API_KEY environment variable"); | |
} | |
// URDF Joint type definition | |
interface URDFJoint { | |
name: string; | |
type: string; | |
parent: string; | |
child: string; | |
axis?: { | |
xyz: string; | |
}; | |
origin?: { | |
xyz: string; | |
rpy: string; | |
}; | |
limits?: { | |
lower?: number; | |
upper?: number; | |
effort?: number; | |
velocity?: number; | |
}; | |
} | |
// URDF Element interface to avoid 'any' type | |
interface URDFElement { | |
name?: string; | |
type?: string; | |
parent?: { link?: string }; | |
child?: { link?: string }; | |
axis?: { xyz?: string }; | |
origin?: { xyz?: string; rpy?: string }; | |
limit?: { | |
lower?: number; | |
upper?: number; | |
effort?: number; | |
velocity?: number; | |
}; | |
} | |
// Animation request definition | |
interface AnimationRequest { | |
robotName: string; | |
urdfContent: string; | |
description: string; | |
} | |
// Joint animation configuration | |
interface JointAnimationConfig { | |
name: string; | |
type: "sine" | "linear" | "constant"; | |
min: number; | |
max: number; | |
speed: number; | |
offset: number; | |
isDegrees?: boolean; | |
} | |
// Robot animation configuration | |
interface RobotAnimationConfig { | |
joints: JointAnimationConfig[]; | |
speedMultiplier?: number; | |
} | |
// CORS headers for cross-origin requests | |
const corsHeaders = { | |
"Access-Control-Allow-Origin": "*", | |
"Access-Control-Allow-Headers": | |
"authorization, x-client-info, apikey, content-type", | |
"Access-Control-Allow-Methods": "POST, OPTIONS", | |
}; | |
// Generate animation configuration using OpenAI | |
async function generateAnimationConfig( | |
joints: URDFJoint[], | |
description: string, | |
robotName: string | |
): Promise<RobotAnimationConfig> { | |
// Filter out fixed joints as they can't be animated | |
const movableJoints = joints.filter( | |
(joint) => | |
joint.type !== "fixed" && | |
joint.name && | |
(joint.type === "revolute" || | |
joint.type === "continuous" || | |
joint.type === "prismatic") | |
); | |
// Create a description of available joints for the AI | |
const jointsDescription = movableJoints | |
.map((joint) => { | |
const limits = joint.limits | |
? `(limits: ${ | |
joint.limits.lower !== undefined ? joint.limits.lower : "none" | |
} to ${ | |
joint.limits.upper !== undefined ? joint.limits.upper : "none" | |
})` | |
: "(no limits)"; | |
const axisInfo = joint.axis ? `axis: ${joint.axis.xyz}` : "no axis info"; | |
return `- ${joint.name}: type=${joint.type}, connects ${joint.parent} to ${joint.child}, ${axisInfo} ${limits}`; | |
}) | |
.join("\n"); | |
// Create prompt for OpenAI | |
const prompt = ` | |
You are a robotics animation expert. I need you to create animation configurations for a robot named "${robotName}". | |
The user wants the following animation: "${description}" | |
Here are the available movable joints in the robot: | |
${jointsDescription} | |
Based on these joints and the user's description, create a valid animation configuration in JSON format. | |
The animation configuration should include: | |
1. A list of joint animations (only include joints that should move) | |
2. For each joint, specify: | |
- name: The exact joint name from the list above (string) | |
- type: Must be exactly one of "sine", "linear", or "constant" (string) | |
- min: Minimum position value (number, respect joint limits if available) | |
- max: Maximum position value (number, respect joint limits if available) | |
- speed: Speed multiplier (number, 0.5-2.0 is a reasonable range, lower is slower) | |
- offset: Phase offset in radians (number, 0 to 2π, useful for coordinating multiple joints) | |
- isDegrees: Optional boolean, set to true if the min/max values are in degrees rather than radians | |
3. An optional global speedMultiplier (number, default: 1.0) | |
You must return ONLY valid JSON matching this EXACT schema: | |
{ | |
"joints": [ | |
{ | |
"name": string, | |
"type": "sine" | "linear" | "constant", | |
"min": number, | |
"max": number, | |
"speed": number, | |
"offset": number, | |
"isDegrees"?: boolean | |
} | |
], | |
"speedMultiplier"?: number | |
} | |
Important rules: | |
- Only include movable joints from the list provided | |
- Respect joint limits when available | |
- Create natural, coordinated movements that match the user's description | |
- If the description mentions specific joints, prioritize animating those | |
- For walking animations, coordinate leg joints with appropriate phase offsets | |
- For realistic motion, use sine waves with different offsets for natural movement | |
- DO NOT ADD ANY PROPERTIES that aren't in the schema above | |
- DO NOT INCLUDE customEasing or any other properties not in the schema | |
- Return ONLY valid JSON without comments or explanations | |
`; | |
try { | |
// Call OpenAI API | |
const response = await fetch("https://api.openai.com/v1/chat/completions", { | |
method: "POST", | |
headers: { | |
"Content-Type": "application/json", | |
Authorization: `Bearer ${OPENAI_API_KEY}`, | |
}, | |
body: JSON.stringify({ | |
model: "gpt-4o-mini", | |
messages: [ | |
{ | |
role: "system", | |
content: | |
"You are a robotics animation expert that produces only valid JSON as output.", | |
}, | |
{ | |
role: "user", | |
content: prompt, | |
}, | |
], | |
temperature: 0.7, | |
max_tokens: 1000, | |
response_format: { type: "json_object" }, | |
}), | |
}); | |
if (!response.ok) { | |
const errorData = await response.json(); | |
console.error("OpenAI API error:", errorData); | |
throw new Error(`OpenAI API error: ${response.status}`); | |
} | |
const data = await response.json(); | |
const animationText = data.choices[0].message.content.trim(); | |
// Log the raw response for debugging | |
console.log( | |
"Raw OpenAI response:", | |
animationText.substring(0, 200) + | |
(animationText.length > 200 ? "..." : "") | |
); | |
// Parse JSON from the response | |
let animationConfig: RobotAnimationConfig; | |
try { | |
// Since we're using response_format: { type: "json_object" }, this should always be valid JSON | |
animationConfig = JSON.parse(animationText); | |
} catch (e) { | |
console.error("Failed to parse JSON response:", e); | |
throw new Error( | |
"Could not parse animation configuration from AI response" | |
); | |
} | |
// Validate the animation config against our RobotAnimationConfig type | |
if (!animationConfig.joints || !Array.isArray(animationConfig.joints)) { | |
throw new Error( | |
"Invalid animation configuration: missing or invalid joints array" | |
); | |
} | |
// Default speedMultiplier if not provided | |
if (animationConfig.speedMultiplier === undefined) { | |
animationConfig.speedMultiplier = 1.0; | |
} else if (typeof animationConfig.speedMultiplier !== "number") { | |
throw new Error("Invalid speedMultiplier: must be a number"); | |
} | |
// Validate all joints have required properties and correct types | |
for (const joint of animationConfig.joints) { | |
// Check required properties | |
if (!joint.name || typeof joint.name !== "string") { | |
throw new Error("Invalid joint: name is required and must be a string"); | |
} | |
if (!joint.type || !["sine", "linear", "constant"].includes(joint.type)) { | |
throw new Error( | |
`Invalid joint type: ${joint.type} for joint ${joint.name}. Must be "sine", "linear", or "constant"` | |
); | |
} | |
if (joint.min === undefined || typeof joint.min !== "number") { | |
throw new Error( | |
`Invalid min value for joint ${joint.name}: must be a number` | |
); | |
} | |
if (joint.max === undefined || typeof joint.max !== "number") { | |
throw new Error( | |
`Invalid max value for joint ${joint.name}: must be a number` | |
); | |
} | |
if (joint.speed === undefined || typeof joint.speed !== "number") { | |
throw new Error( | |
`Invalid speed value for joint ${joint.name}: must be a number` | |
); | |
} | |
if (joint.offset === undefined || typeof joint.offset !== "number") { | |
throw new Error( | |
`Invalid offset value for joint ${joint.name}: must be a number` | |
); | |
} | |
// Check optional properties | |
if ( | |
joint.isDegrees !== undefined && | |
typeof joint.isDegrees !== "boolean" | |
) { | |
throw new Error( | |
`Invalid isDegrees value for joint ${joint.name}: must be a boolean` | |
); | |
} | |
// Ensure no custom properties that aren't in our type | |
const allowedProperties = [ | |
"name", | |
"type", | |
"min", | |
"max", | |
"speed", | |
"offset", | |
"isDegrees", | |
]; | |
const extraProperties = Object.keys(joint).filter( | |
(key) => !allowedProperties.includes(key) | |
); | |
if (extraProperties.length > 0) { | |
console.warn( | |
`Warning: Joint ${ | |
joint.name | |
} has extra properties not in JointAnimationConfig: ${extraProperties.join( | |
", " | |
)}` | |
); | |
// Remove extra properties to ensure exact type match | |
extraProperties.forEach((prop) => { | |
delete joint[prop]; | |
}); | |
} | |
} | |
// Clean the final object to ensure it matches our type exactly | |
const cleanedConfig: RobotAnimationConfig = { | |
joints: animationConfig.joints.map((joint) => ({ | |
name: joint.name, | |
type: joint.type, | |
min: joint.min, | |
max: joint.max, | |
speed: joint.speed, | |
offset: joint.offset, | |
...(joint.isDegrees !== undefined && { isDegrees: joint.isDegrees }), | |
})), | |
speedMultiplier: animationConfig.speedMultiplier, | |
}; | |
// Log the cleaned config for debugging | |
console.log( | |
`Cleaned config: ${cleanedConfig.joints.length} joints, speedMultiplier: ${cleanedConfig.speedMultiplier}` | |
); | |
return cleanedConfig; | |
} catch (error) { | |
console.error("Error generating animation:", error); | |
// Return a simple default animation if generation fails | |
// Get at most 2 movable joints to animate | |
const jointsToAnimate = movableJoints.slice(0, 2); | |
if (jointsToAnimate.length === 0) { | |
// If no movable joints found, create a message | |
throw new Error( | |
"Cannot generate animation: No movable joints found in the robot model" | |
); | |
} | |
// Create a simple, safe animation for the available joints | |
const fallbackConfig: RobotAnimationConfig = { | |
joints: jointsToAnimate.map((joint) => ({ | |
name: joint.name, | |
type: "sine" as const, | |
min: joint.limits?.lower !== undefined ? joint.limits.lower : -0.5, | |
max: joint.limits?.upper !== undefined ? joint.limits.upper : 0.5, | |
speed: 1.0, | |
offset: 0, | |
isDegrees: false, | |
})), | |
speedMultiplier: 1.0, | |
}; | |
console.log("Using fallback animation config:", fallbackConfig); | |
return fallbackConfig; | |
} | |
} | |
// Parse URDF XML and extract joint information | |
function parseUrdfForJoints(urdfContent: string): URDFJoint[] { | |
const parser = new XMLParser({ | |
ignoreAttributes: false, | |
attributeNamePrefix: "", | |
parseAttributeValue: true, | |
}); | |
try { | |
const doc = parser.parse(urdfContent); | |
if (!doc || !doc.robot) { | |
throw new Error("No robot element found in URDF"); | |
} | |
// Extract joints | |
const joints: URDFJoint[] = []; | |
const robotElement = doc.robot; | |
if (robotElement.joint) { | |
const jointElements = Array.isArray(robotElement.joint) | |
? robotElement.joint | |
: [robotElement.joint]; | |
jointElements.forEach((joint: URDFElement) => { | |
const jointData: URDFJoint = { | |
name: joint.name || "", | |
type: joint.type || "", | |
parent: joint.parent?.link || "", | |
child: joint.child?.link || "", | |
}; | |
// Parse axis | |
if (joint.axis) { | |
jointData.axis = { | |
xyz: joint.axis.xyz || "0 0 0", | |
}; | |
} | |
// Parse origin | |
if (joint.origin) { | |
jointData.origin = { | |
xyz: joint.origin.xyz || "0 0 0", | |
rpy: joint.origin.rpy || "0 0 0", | |
}; | |
} | |
// Parse limits | |
if (joint.limit) { | |
jointData.limits = { | |
lower: | |
joint.limit.lower !== undefined ? joint.limit.lower : undefined, | |
upper: | |
joint.limit.upper !== undefined ? joint.limit.upper : undefined, | |
effort: | |
joint.limit.effort !== undefined ? joint.limit.effort : undefined, | |
velocity: | |
joint.limit.velocity !== undefined | |
? joint.limit.velocity | |
: undefined, | |
}; | |
} | |
joints.push(jointData); | |
}); | |
} | |
return joints; | |
} catch (error) { | |
console.error("Error parsing URDF XML:", error); | |
throw new Error( | |
`Could not parse URDF: ${ | |
error instanceof Error ? error.message : String(error) | |
}` | |
); | |
} | |
} | |
// Main server function | |
serve(async (req) => { | |
// Handle CORS preflight requests | |
if (req.method === "OPTIONS") { | |
return new Response("ok", { headers: corsHeaders }); | |
} | |
try { | |
if (req.method !== "POST") { | |
throw new Error("Method not allowed"); | |
} | |
// Parse request | |
const requestData: AnimationRequest = await req.json(); | |
const { robotName, urdfContent, description } = requestData; | |
if (!urdfContent) { | |
throw new Error("No URDF content provided"); | |
} | |
if (!description) { | |
throw new Error("No animation description provided"); | |
} | |
console.log( | |
`Generating animation for ${robotName}: "${description.substring( | |
0, | |
100 | |
)}..."` | |
); | |
// Parse URDF to extract joint information | |
const joints = parseUrdfForJoints(urdfContent); | |
console.log(`Extracted ${joints.length} joints from URDF`); | |
// Generate animation configuration | |
const animationConfig = await generateAnimationConfig( | |
joints, | |
description, | |
robotName | |
); | |
console.log( | |
`Generated animation with ${animationConfig.joints.length} animated joints` | |
); | |
// Return the animation configuration | |
return new Response(JSON.stringify(animationConfig), { | |
headers: { | |
...corsHeaders, | |
"Content-Type": "application/json", | |
}, | |
}); | |
} catch (error) { | |
console.error("Error processing animation request:", error); | |
return new Response( | |
JSON.stringify({ | |
error: | |
error instanceof Error ? error.message : "Unknown error occurred", | |
}), | |
{ | |
status: 500, | |
headers: { | |
...corsHeaders, | |
"Content-Type": "application/json", | |
}, | |
} | |
); | |
} | |
}); | |