AgentSkillsCN

octane-grpc

深入掌握 Octane gRPC API 模式、Proto 文件的使用方法、服务层架构,以及常见的 gRPC 操作。适用于实现新 API 调用或调试 gRPC 通信时使用。

SKILL.md
--- frontmatter
name: octane-grpc
description: Deep knowledge of Octane gRPC API patterns, proto file usage, service layer architecture, and common gRPC operations. Use when implementing new API calls or debugging gRPC communication.
triggers:
  - grpc
  - proto
  - octane api
  - service layer
  - node service
  - viewport service
  - scene service

Octane gRPC API Skill

Expert knowledge for working with Octane's gRPC LiveLink API in octaneWebR.

Proto Files Location

All proto definitions live in:

code
server/proto/
├── apinodesystem_3.proto       # Node operations (create, delete, connect)
├── apiscenegraph_3.proto        # Scene graph queries
├── apirendertarget_3.proto      # Viewport/render operations
├── apimaterialdatabase_3.proto  # LiveDB/LocalDB
└── ...

gRPC Call Pattern

1. Find the Proto Method

bash
# Search for available methods
grep -r "rpc MethodName" server/proto/

# Example: Find node creation
grep -r "rpc Create" server/proto/apinodesystem_3.proto

2. Implement in Service

typescript
// services/octane/NodeService.ts
import { BaseService } from './BaseService';

export class NodeService extends BaseService {
  async createNode(nodeType: string): Promise<number> {
    try {
      // Make gRPC call via fetch (proxy handles gRPC-Web conversion)
      const response = await fetch(`${this.serverUrl}/api/node/create`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ nodeType })
      });
      
      if (!response.ok) {
        throw new Error(`Failed to create node: ${response.statusText}`);
      }
      
      const data = await response.json();
      
      // Emit event for UI synchronization
      this.emit('node:created', { 
        handle: data.handle, 
        type: nodeType 
      });
      
      return data.handle;
    } catch (error) {
      console.error('[NodeService] createNode failed:', error);
      throw error;
    }
  }
}

3. Expose in OctaneClient

typescript
// services/OctaneClient.ts
export class OctaneClient extends EventEmitter {
  private _nodeService: NodeService;
  
  constructor() {
    super();
    this._nodeService = new NodeService(this);
  }
  
  // Public API getter
  public get node() {
    return {
      create: (type: string) => this._nodeService.createNode(type),
      delete: (handle: number) => this._nodeService.deleteNode(handle),
      connect: (parent: number, pin: number, child: number) => 
        this._nodeService.connectPinByIndex(parent, pin, child)
    };
  }
}

4. Use in Component

typescript
// components/NodeGraph/index.tsx
import { useOctane } from '../../hooks/useOctane';

const NodeGraph: React.FC = () => {
  const { client, connected } = useOctane();
  
  const handleCreateNode = async (type: string) => {
    if (!connected) return;
    
    try {
      const handle = await client.node.create(type);
      console.log(`Created node: ${handle}`);
    } catch (error) {
      console.error('Failed to create node:', error);
    }
  };
  
  return <button onClick={() => handleCreateNode('NT_DIFFUSE_MAT')}>
    Create Material
  </button>;
};

Common gRPC Operations

Node Operations

typescript
// Create node
const handle = await client.node.create('NT_CAMERA');

// Delete node
await client.node.delete(handle);

// Connect nodes
await client.node.connect(parentHandle, pinIndex, childHandle);

// Get node info
const info = await client.node.getInfo(handle);

// Get node parameters
const params = await client.node.getParameters(handle);

// Set parameter value
await client.node.setParameter(handle, 'paramName', value);

Scene Operations

typescript
// Get scene tree
const tree = await client.scene.getTree();

// Refresh scene
await client.scene.refresh();

// Get render target
const renderTarget = await client.scene.getRenderTarget();

Viewport Operations

typescript
// Start interactive render
await client.viewport.startRender();

// Stop render
await client.viewport.stopRender();

// Get render statistics
const stats = await client.viewport.getStats();

Service Layer Architecture

All services follow this pattern:

typescript
export class MyService extends BaseService {
  // BaseService provides:
  // - this.client: OctaneClient reference
  // - this.serverUrl: API endpoint
  // - this.emit(): Event emitter
  
  async myMethod(param: Type): Promise<ResultType> {
    try {
      // 1. Validate input
      if (!param) throw new Error('Param required');
      
      // 2. Make gRPC call
      const response = await fetch(`${this.serverUrl}/api/endpoint`, {
        method: 'POST',
        body: JSON.stringify({ param })
      });
      
      if (!response.ok) throw new Error(response.statusText);
      const data = await response.json();
      
      // 3. Emit event for UI updates
      this.emit('event:name', { result: data });
      
      // 4. Return result
      return data;
    } catch (error) {
      console.error('[MyService] myMethod failed:', error);
      throw error;
    }
  }
}

Event-Driven Communication

Services emit events for UI synchronization:

typescript
// In service
this.emit('node:created', { handle, type });
this.emit('node:deleted', { handle });
this.emit('node:replaced', { oldHandle, newHandle });
this.emit('scene:updated', { tree });
this.emit('render:started', {});
typescript
// In component
useEffect(() => {
  const handleNodeCreated = (data: { handle: number, type: string }) => {
    console.log('Node created:', data);
    // Update UI state
  };
  
  client.on('node:created', handleNodeCreated);
  return () => client.off('node:created', handleNodeCreated);
}, [client]);

Common Gotchas

1. Node Handles are Numbers

typescript
// ❌ Wrong
const handle: string = await client.node.create('NT_CAMERA');

// ✅ Correct
const handle: number = await client.node.create('NT_CAMERA');

2. Check Connection State

typescript
// ❌ Wrong - will fail if disconnected
await client.node.create('NT_CAMERA');

// ✅ Correct
if (!connected) {
  console.warn('Not connected to Octane');
  return;
}
await client.node.create('NT_CAMERA');

3. Pin Indices are 0-based

typescript
// Pins: [0: Diffuse, 1: Specular, 2: Roughness]
await client.node.connect(materialHandle, 0, diffuseTextureHandle); // ✅
await client.node.connect(materialHandle, 1, diffuseTextureHandle); // ❌ Wrong pin!

4. Handle API Errors

typescript
// ❌ Wrong - error swallowed
const handle = await client.node.create('NT_INVALID_TYPE');

// ✅ Correct
try {
  const handle = await client.node.create('NT_INVALID_TYPE');
} catch (error) {
  console.error('Failed to create node:', error);
  // Show user-friendly error
}

Proxy Server Pattern

The Vite plugin acts as a gRPC-Web proxy:

typescript
// vite-plugin-octane-grpc.ts
export function octaneGrpcPlugin(): Plugin {
  return {
    name: 'octane-grpc',
    configureServer(server) {
      server.middlewares.use('/api', async (req, res) => {
        // Convert HTTP request to gRPC call
        const grpcClient = new OctaneClient('host.docker.internal:51022');
        const result = await grpcClient.callMethod(req.body);
        res.json(result);
      });
    }
  };
}

Client makes standard HTTP calls:

typescript
fetch('/api/node/create', { method: 'POST', body: JSON.stringify({ type }) })

Proxy converts to gRPC and calls Octane.

Debugging gRPC Calls

1. Check Server Logs

bash
# Look for gRPC connection status
[Octane Client] Connected to Octane at host.docker.internal:51022

# Look for method calls
[NodeService] Creating node: NT_CAMERA
[NodeService] Node created: 42

2. Check Network Tab

  • Open browser DevTools → Network
  • Filter for /api/
  • Check request payload and response
  • Look for 4xx/5xx errors

3. Check Proto Definition

bash
# Find the exact method signature
grep -A 5 "rpc Create" server/proto/apinodesystem_3.proto

4. Test with cURL

bash
# Test endpoint directly
curl -X POST http://localhost:57341/api/node/create \
  -H "Content-Type: application/json" \
  -d '{"nodeType": "NT_CAMERA"}'

Recent Discoveries

Node Replacement Pattern (Jan 2025)

When replacing a node while maintaining connections:

typescript
async replaceNode(oldHandle: number, newType: string): Promise<number> {
  // 1. Get parent connections FIRST (before deleting)
  const parents = await this.getNodeParents(oldHandle);
  
  // 2. Create new node
  const newHandle = await this.createNode(newType);
  
  // 3. Reconnect to parents
  for (const { parentHandle, pinIndex } of parents) {
    await this.connectPinByIndex(parentHandle, pinIndex, newHandle);
  }
  
  // 4. Delete old node LAST
  await this.deleteNode(oldHandle);
  
  return newHandle;
}

Key insight: Get parent connections before deletion, or they're lost!

When to Update This Skill

Add new knowledge when you:

  • Discover a new proto method
  • Find a clever gRPC pattern
  • Debug a tricky gRPC issue
  • Learn about undocumented API behavior
  • Create a useful helper for gRPC calls