// @deno-types="npm:@types/node" import { XMLParser } from "npm:fast-xml-parser@4.3.5"; import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.38.4"; // 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 { // 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", }, } ); } });