Initial commit
This commit is contained in:
43
skills/scripts/eslint.config.mjs
Normal file
43
skills/scripts/eslint.config.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import typescriptParser from '@typescript-eslint/parser';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'*.backup/**'
|
||||
]
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint
|
||||
},
|
||||
rules: {
|
||||
// TypeScript 규칙
|
||||
'@typescript-eslint/no-explicit-any': 'error', // any 사용 금지 - 타입 가드 또는 명시적 타입 사용
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_'
|
||||
}],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
|
||||
// 일반 규칙
|
||||
'no-console': 'off', // CLI 도구이므로 console 사용 허용
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error'
|
||||
}
|
||||
}
|
||||
];
|
||||
71
skills/scripts/install-addon.py
Executable file
71
skills/scripts/install-addon.py
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Blender Toolkit Addon Auto-Installer
|
||||
Blender를 백그라운드에서 실행하여 애드온을 자동으로 설치/활성화합니다.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def install_addon():
|
||||
"""애드온 설치 및 활성화"""
|
||||
|
||||
# 애드온 경로 (이 스크립트의 부모 디렉토리)
|
||||
script_dir = Path(__file__).parent.absolute()
|
||||
addon_dir = script_dir.parent / "addon"
|
||||
addon_init = addon_dir / "__init__.py"
|
||||
|
||||
if not addon_init.exists():
|
||||
print(f"❌ Error: Addon not found at {addon_init}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"📦 Installing Blender Toolkit addon from: {addon_dir}")
|
||||
|
||||
try:
|
||||
# 애드온이 이미 설치되어 있으면 먼저 제거
|
||||
addon_name = "blender_toolkit_websocket"
|
||||
if addon_name in bpy.context.preferences.addons:
|
||||
print(f"🔄 Removing existing addon: {addon_name}")
|
||||
bpy.ops.preferences.addon_disable(module=addon_name)
|
||||
bpy.ops.preferences.addon_remove(module=addon_name)
|
||||
|
||||
# 애드온 디렉토리를 Blender scripts path에 추가
|
||||
scripts_path = bpy.utils.user_resource('SCRIPTS', path="addons")
|
||||
|
||||
# 심볼릭 링크 또는 복사 방식으로 설치
|
||||
import shutil
|
||||
target_path = Path(scripts_path) / "blender_toolkit_websocket"
|
||||
|
||||
if target_path.exists():
|
||||
print(f"🔄 Removing existing installation at {target_path}")
|
||||
shutil.rmtree(target_path)
|
||||
|
||||
print(f"📋 Copying addon to: {target_path}")
|
||||
shutil.copytree(addon_dir, target_path)
|
||||
|
||||
# 애드온 활성화
|
||||
print(f"✅ Enabling addon: {addon_name}")
|
||||
bpy.ops.preferences.addon_enable(module=addon_name)
|
||||
|
||||
# User preferences 저장
|
||||
bpy.ops.wm.save_userpref()
|
||||
|
||||
print("✅ Addon installed and enabled successfully!")
|
||||
print("\n📝 Next steps:")
|
||||
print(" 1. Start Blender normally")
|
||||
print(" 2. The WebSocket server will auto-start on port 9400")
|
||||
print(" 3. Use CLI: node dist/cli/cli.js <command>")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error installing addon: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = install_addon()
|
||||
sys.exit(exit_code)
|
||||
39
skills/scripts/package.json
Normal file
39
skills/scripts/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "blender-toolkit-cli",
|
||||
"version": "1.4.4",
|
||||
"description": "Blender automation CLI with geometry, materials, modifiers, collections, animation retargeting, and WebSocket-based control",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"blender-toolkit": "./dist/cli/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"keywords": [
|
||||
"blender",
|
||||
"animation",
|
||||
"retargeting",
|
||||
"mixamo",
|
||||
"websocket",
|
||||
"geometry",
|
||||
"materials",
|
||||
"modifiers",
|
||||
"collections",
|
||||
"3d",
|
||||
"automation"
|
||||
],
|
||||
"author": "Dev GOM",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"commander": "^14.0.2",
|
||||
"ws": "^8.14.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
198
skills/scripts/src/blender/client.ts
Normal file
198
skills/scripts/src/blender/client.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Blender WebSocket Client
|
||||
* Blender Python 애드온과 통신하기 위한 WebSocket 클라이언트
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { EventEmitter } from 'events';
|
||||
import { BLENDER } from '../constants';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export interface BlenderMessage {
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface BlenderResponse {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BlenderEvent {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export class BlenderClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private messageId = 0;
|
||||
private wsUrl: string;
|
||||
private port: number;
|
||||
|
||||
constructor(port: number = BLENDER.DEFAULT_PORT) {
|
||||
super();
|
||||
this.port = port;
|
||||
this.wsUrl = `ws://${BLENDER.LOCALHOST}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blender에 WebSocket으로 연결
|
||||
*/
|
||||
async connect(port?: number): Promise<void> {
|
||||
// port가 제공되면 업데이트
|
||||
if (port !== undefined) {
|
||||
this.port = port;
|
||||
this.wsUrl = `ws://${BLENDER.LOCALHOST}:${port}`;
|
||||
}
|
||||
|
||||
log.info(`Connecting to Blender WebSocket: ${this.wsUrl}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.ws) {
|
||||
this.ws.terminate();
|
||||
}
|
||||
const errorMsg = `Connection timeout (${BLENDER.WS_TIMEOUT}ms)`;
|
||||
log.error(errorMsg);
|
||||
reject(new Error(errorMsg));
|
||||
}, BLENDER.WS_TIMEOUT);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
clearTimeout(timeout);
|
||||
log.info('WebSocket connection established');
|
||||
|
||||
// 전역 메시지 핸들러 설정 (이벤트 수신용)
|
||||
if (this.ws) {
|
||||
this.ws.on('message', (data: WebSocket.Data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
// 이벤트는 id가 없고 method만 있음
|
||||
if (!message.id && message.method) {
|
||||
this.emit('event', message as BlenderEvent);
|
||||
this.emit(message.method, message.params);
|
||||
}
|
||||
} catch (error) {
|
||||
// JSON 파싱 에러는 무시하되 디버그 모드에서는 로깅
|
||||
if (process.env.DEBUG) {
|
||||
console.debug('[BlenderClient] Event JSON parse error:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
log.error(`WebSocket error: ${error.message}`);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
log.info('WebSocket connection closed');
|
||||
this.emit('disconnected');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Blender에 명령 전송 및 응답 대기
|
||||
*/
|
||||
async sendCommand<T = any>(
|
||||
method: string,
|
||||
params?: unknown
|
||||
): Promise<T> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
const errorMsg = 'Not connected to Blender';
|
||||
log.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Capture ws reference for use in callbacks
|
||||
const ws = this.ws;
|
||||
const id = ++this.messageId;
|
||||
const message: BlenderMessage = { id, method, params };
|
||||
|
||||
log.debug(`Sending command: ${method}`, params);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.off('message', messageHandler);
|
||||
reject(new Error(`Command timeout: ${method}`));
|
||||
}, BLENDER.WS_TIMEOUT);
|
||||
|
||||
// 응답 대기
|
||||
const messageHandler = (data: WebSocket.Data) => {
|
||||
try {
|
||||
const response = JSON.parse(data.toString()) as BlenderResponse;
|
||||
|
||||
if (response.id === id) {
|
||||
clearTimeout(timeout);
|
||||
ws.off('message', messageHandler);
|
||||
|
||||
if (response.error) {
|
||||
log.error(`Command ${method} failed: ${response.error.message}`);
|
||||
reject(new Error(response.error.message));
|
||||
} else {
|
||||
log.debug(`Command ${method} completed successfully`);
|
||||
resolve(response.result as T);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// JSON 파싱 에러는 무시 (다른 메시지일 수 있음)
|
||||
// 디버그 모드에서만 로깅
|
||||
if (process.env.DEBUG) {
|
||||
console.debug('[BlenderClient] JSON parse error:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.on('message', messageHandler);
|
||||
|
||||
// 메시지 전송
|
||||
ws.send(JSON.stringify(message), (error) => {
|
||||
if (error) {
|
||||
clearTimeout(timeout);
|
||||
ws.off('message', messageHandler);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 연결 종료
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 종료 (disconnect의 alias)
|
||||
*/
|
||||
close(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 상태 확인
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
352
skills/scripts/src/blender/config.ts
Normal file
352
skills/scripts/src/blender/config.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Configuration management for Blender WebSocket port and state
|
||||
* Browser-Pilot의 config 시스템을 참고한 프로젝트별 설정 관리
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, statSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { createServer } from 'net';
|
||||
import { BLENDER, FS } from '../constants';
|
||||
|
||||
export interface ProjectConfig {
|
||||
rootPath: string;
|
||||
port: number;
|
||||
outputDir: string;
|
||||
lastUsed: string | null;
|
||||
autoCleanup: boolean;
|
||||
}
|
||||
|
||||
export interface SharedBlenderConfig {
|
||||
projects: {
|
||||
[projectName: string]: ProjectConfig;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 타임스탬프 문자열 생성
|
||||
* Format: YYYY-MM-DD HH:MM:SS.mmm
|
||||
*/
|
||||
function getLocalTimestamp(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 공유 설정 파일 경로 가져오기
|
||||
* Browser Pilot 패턴: CLAUDE_PLUGIN_ROOT 환경 변수 사용 (fallback 없음)
|
||||
* 위치: $CLAUDE_PLUGIN_ROOT/skills/blender-config.json
|
||||
*/
|
||||
function getSharedConfigPath(): string {
|
||||
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
|
||||
if (!pluginRoot) {
|
||||
console.error('Error: CLAUDE_PLUGIN_ROOT environment variable not set');
|
||||
console.error('This tool must be run from Claude Code environment');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return join(pluginRoot, 'skills', 'blender-config.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 루트 찾기
|
||||
* Browser Pilot 패턴: 환경 변수 검증 후 fallback
|
||||
*/
|
||||
export function findProjectRoot(): string {
|
||||
const projectDir = process.env.CLAUDE_PROJECT_DIR;
|
||||
|
||||
if (projectDir) {
|
||||
// 경로 존재 여부 확인
|
||||
if (!existsSync(projectDir)) {
|
||||
console.warn(`Warning: CLAUDE_PROJECT_DIR points to non-existent path: ${projectDir}`);
|
||||
console.warn('Falling back to current working directory');
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
// 디렉토리인지 확인
|
||||
try {
|
||||
const stats = statSync(projectDir);
|
||||
if (!stats.isDirectory()) {
|
||||
console.error(`Error: CLAUDE_PROJECT_DIR is not a directory: ${projectDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return projectDir;
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Cannot access CLAUDE_PROJECT_DIR: ${projectDir}`);
|
||||
console.warn('Falling back to current working directory');
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
|
||||
// 환경 변수 없으면 현재 작업 디렉토리 사용
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 이름 가져오기 (폴더 이름)
|
||||
*/
|
||||
function getProjectName(projectRoot: string): string {
|
||||
return basename(projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 출력 디렉토리 가져오기
|
||||
*/
|
||||
export function getOutputDir(): string {
|
||||
const projectRoot = findProjectRoot();
|
||||
const outputDir = join(projectRoot, FS.OUTPUT_DIR);
|
||||
|
||||
// .blender-toolkit 디렉토리 생성
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// .gitignore 생성
|
||||
const gitignorePath = join(outputDir, '.gitignore');
|
||||
if (!existsSync(gitignorePath)) {
|
||||
writeFileSync(gitignorePath, FS.GITIGNORE_CONTENT, 'utf-8');
|
||||
}
|
||||
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 공유 설정 로드
|
||||
*/
|
||||
export function loadSharedConfig(): SharedBlenderConfig {
|
||||
const configPath = getSharedConfigPath();
|
||||
|
||||
// 설정 파일 디렉토리 생성
|
||||
const configDir = join(configPath, '..');
|
||||
if (!existsSync(configDir)) {
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
// 기본 설정 생성
|
||||
const defaultConfig: SharedBlenderConfig = {
|
||||
projects: {}
|
||||
};
|
||||
saveSharedConfig(defaultConfig);
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = readFileSync(configPath, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load shared config:', error);
|
||||
console.warn('Returning empty config - existing settings may be lost');
|
||||
console.warn(`Config path: ${configPath}`);
|
||||
return {
|
||||
projects: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공유 설정 저장 (원자적 쓰기)
|
||||
* Browser Pilot 패턴: 임시 파일에 쓴 후 rename으로 원자적 교체
|
||||
*/
|
||||
export function saveSharedConfig(config: SharedBlenderConfig): void {
|
||||
const configPath = getSharedConfigPath();
|
||||
const tempPath = join(tmpdir(), `blender-config-${Date.now()}-${process.pid}.tmp`);
|
||||
|
||||
try {
|
||||
// 1. Write to temporary file first
|
||||
writeFileSync(tempPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
|
||||
// 2. Atomic rename (replaces existing file)
|
||||
renameSync(tempPath, configPath);
|
||||
} catch (error) {
|
||||
// Clean up temporary file if it exists
|
||||
if (existsSync(tempPath)) {
|
||||
try {
|
||||
unlinkSync(tempPath);
|
||||
} catch (cleanupError) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('Failed to save shared config:', errorMessage);
|
||||
console.warn(`Config path: ${configPath}`);
|
||||
throw new Error(`Configuration save failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 프로젝트의 설정 가져오기
|
||||
* 없으면 사용 가능한 포트로 자동 생성
|
||||
*/
|
||||
export async function getProjectConfig(): Promise<ProjectConfig> {
|
||||
const projectRoot = findProjectRoot();
|
||||
const projectName = getProjectName(projectRoot);
|
||||
const sharedConfig = loadSharedConfig();
|
||||
|
||||
// rootPath로 기존 설정 찾기 (이름이 바뀐 경우 대비)
|
||||
const existingEntry = Object.entries(sharedConfig.projects).find(
|
||||
([_, config]) => config.rootPath === projectRoot
|
||||
);
|
||||
|
||||
if (existingEntry) {
|
||||
const [existingName, config] = existingEntry;
|
||||
|
||||
// 이름이 바뀐 경우 업데이트
|
||||
if (existingName !== projectName) {
|
||||
delete sharedConfig.projects[existingName];
|
||||
sharedConfig.projects[projectName] = config;
|
||||
saveSharedConfig(sharedConfig);
|
||||
console.log(`📝 Updated project name: ${existingName} → ${projectName}`);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// 같은 이름이 다른 경로에 있는지 확인
|
||||
if (sharedConfig.projects[projectName]) {
|
||||
console.warn(`⚠️ Project name "${projectName}" already exists with different path`);
|
||||
console.warn(` Existing: ${sharedConfig.projects[projectName].rootPath}`);
|
||||
console.warn(` Current: ${projectRoot}`);
|
||||
throw new Error(`Project name conflict: "${projectName}"`);
|
||||
}
|
||||
|
||||
// 새 프로젝트 설정 생성
|
||||
const basePort = parseInt(process.env.BLENDER_WS_PORT || String(BLENDER.DEFAULT_PORT));
|
||||
|
||||
// 사용 중인 포트 목록
|
||||
const usedPorts = Object.values(sharedConfig.projects).map(p => p.port);
|
||||
let port = basePort;
|
||||
|
||||
// 사용 가능한 포트 찾기
|
||||
while (usedPorts.includes(port) || !(await isPortAvailable(port))) {
|
||||
port++;
|
||||
if (port > basePort + BLENDER.PORT_RANGE_MAX) {
|
||||
throw new Error(
|
||||
`No available port found in range ${basePort}-${basePort + BLENDER.PORT_RANGE_MAX}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
rootPath: projectRoot,
|
||||
port,
|
||||
outputDir: FS.OUTPUT_DIR,
|
||||
lastUsed: getLocalTimestamp(),
|
||||
autoCleanup: false // 안전을 위해 기본값 false
|
||||
};
|
||||
|
||||
// 설정 저장
|
||||
sharedConfig.projects[projectName] = projectConfig;
|
||||
saveSharedConfig(sharedConfig);
|
||||
|
||||
console.log(`📝 Created config for project: ${projectName}`);
|
||||
console.log(` Path: ${projectRoot}`);
|
||||
console.log(` Port: ${port}`);
|
||||
|
||||
return projectConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 사용 시간 업데이트
|
||||
*/
|
||||
export function updateProjectLastUsed(): void {
|
||||
const projectRoot = findProjectRoot();
|
||||
const projectName = getProjectName(projectRoot);
|
||||
const sharedConfig = loadSharedConfig();
|
||||
|
||||
if (sharedConfig.projects[projectName]) {
|
||||
sharedConfig.projects[projectName].lastUsed = getLocalTimestamp();
|
||||
saveSharedConfig(sharedConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 포트 가져오기
|
||||
*/
|
||||
export async function getProjectPort(): Promise<number> {
|
||||
const config = await getProjectConfig();
|
||||
return config.port;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 프로젝트 목록
|
||||
*/
|
||||
export function listProjects(): void {
|
||||
const sharedConfig = loadSharedConfig();
|
||||
const projects = Object.entries(sharedConfig.projects);
|
||||
|
||||
if (projects.length === 0) {
|
||||
console.log('No projects configured yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n📋 Configured Projects (${projects.length}):\n`);
|
||||
projects.forEach(([name, config]) => {
|
||||
console.log(` ${name}`);
|
||||
console.log(` ├─ Path: ${config.rootPath}`);
|
||||
console.log(` ├─ Port: ${config.port}`);
|
||||
console.log(` ├─ Output: ${config.outputDir}`);
|
||||
console.log(` └─ Last Used: ${config.lastUsed || 'Never'}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 설정 초기화
|
||||
*/
|
||||
export function resetProjectConfig(): void {
|
||||
const projectRoot = findProjectRoot();
|
||||
const projectName = getProjectName(projectRoot);
|
||||
const sharedConfig = loadSharedConfig();
|
||||
|
||||
delete sharedConfig.projects[projectName];
|
||||
saveSharedConfig(sharedConfig);
|
||||
|
||||
console.log(`🗑️ Removed config for project: ${projectName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 포트 사용 가능 여부 확인
|
||||
*/
|
||||
export async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
server.listen(port, BLENDER.LOCALHOST);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 포트 찾기
|
||||
*/
|
||||
export async function findAvailablePort(
|
||||
startPort = BLENDER.DEFAULT_PORT,
|
||||
maxAttempts = BLENDER.PORT_RANGE_MAX
|
||||
): Promise<number> {
|
||||
for (let port = startPort; port < startPort + maxAttempts; port++) {
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`
|
||||
);
|
||||
}
|
||||
65
skills/scripts/src/blender/mixamo.ts
Normal file
65
skills/scripts/src/blender/mixamo.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Mixamo Integration - Manual Download Support
|
||||
* Mixamo does not provide an official API, so users must download animations manually
|
||||
*/
|
||||
|
||||
/**
|
||||
* Provides manual download instructions and popular animation suggestions
|
||||
*/
|
||||
export class MixamoHelper {
|
||||
/**
|
||||
* Get manual download instructions for a specific animation
|
||||
*/
|
||||
getManualDownloadInstructions(animationName: string): string {
|
||||
return `
|
||||
📝 Manual Download Instructions for "${animationName}":
|
||||
|
||||
1. Visit https://www.mixamo.com
|
||||
2. Login with your Adobe account
|
||||
3. Search for "${animationName}"
|
||||
4. Select the animation
|
||||
5. Click "Download" button
|
||||
6. Choose settings:
|
||||
- Format: FBX (.fbx)
|
||||
- Skin: Without Skin (recommended for retargeting)
|
||||
- FPS: 30
|
||||
7. Save to your project's animations folder
|
||||
8. Return here and provide the file path
|
||||
|
||||
Alternative: You can also drag & drop the FBX file into Blender manually.
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of popular Mixamo animations
|
||||
*/
|
||||
getPopularAnimations(): Array<{ name: string; category: string }> {
|
||||
return [
|
||||
{ name: 'Walking', category: 'Locomotion' },
|
||||
{ name: 'Running', category: 'Locomotion' },
|
||||
{ name: 'Idle', category: 'Idle' },
|
||||
{ name: 'Jump', category: 'Action' },
|
||||
{ name: 'Dancing', category: 'Dance' },
|
||||
{ name: 'Sitting', category: 'Sitting' },
|
||||
{ name: 'Standing', category: 'Standing' },
|
||||
{ name: 'Fighting', category: 'Combat' },
|
||||
{ name: 'Waving', category: 'Gesture' },
|
||||
{ name: 'Talking', category: 'Gesture' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download settings recommendation
|
||||
*/
|
||||
getRecommendedSettings(): {
|
||||
format: string;
|
||||
skin: string;
|
||||
fps: number;
|
||||
} {
|
||||
return {
|
||||
format: 'FBX (.fbx)',
|
||||
skin: 'Without Skin',
|
||||
fps: 30,
|
||||
};
|
||||
}
|
||||
}
|
||||
169
skills/scripts/src/blender/retargeting.ts
Normal file
169
skills/scripts/src/blender/retargeting.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Animation Retargeting Controller
|
||||
* Mixamo 애니메이션을 사용자 캐릭터에 리타게팅
|
||||
*/
|
||||
|
||||
import { BlenderClient } from './client';
|
||||
import { RETARGETING, TIMING } from '../constants';
|
||||
|
||||
export interface RetargetOptions {
|
||||
sourceArmature: string; // Mixamo 아마추어 이름
|
||||
targetArmature: string; // 사용자 캐릭터 아마추어 이름
|
||||
boneMapping?: 'auto' | 'mixamo_to_rigify' | 'custom';
|
||||
customBoneMap?: Record<string, string>;
|
||||
preserveRotation?: boolean;
|
||||
preserveLocation?: boolean;
|
||||
}
|
||||
|
||||
export interface BoneInfo {
|
||||
name: string;
|
||||
parent: string | null;
|
||||
children: string[];
|
||||
}
|
||||
|
||||
export class RetargetingController {
|
||||
private client: BlenderClient;
|
||||
|
||||
constructor(client: BlenderClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 아마추어의 본 목록 가져오기
|
||||
*/
|
||||
async getBones(armatureName: string): Promise<BoneInfo[]> {
|
||||
return await this.client.sendCommand<BoneInfo[]>('Armature.getBones', {
|
||||
armatureName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 본 매핑 생성
|
||||
* Mixamo 본 이름과 사용자 캐릭터 본 이름을 매칭
|
||||
*/
|
||||
async autoMapBones(
|
||||
sourceArmature: string,
|
||||
targetArmature: string
|
||||
): Promise<Record<string, string>> {
|
||||
return await this.client.sendCommand<Record<string, string>>(
|
||||
'Retargeting.autoMapBones',
|
||||
{
|
||||
sourceArmature,
|
||||
targetArmature,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 애니메이션 리타게팅 실행
|
||||
*/
|
||||
async retarget(options: RetargetOptions): Promise<void> {
|
||||
const {
|
||||
sourceArmature,
|
||||
targetArmature,
|
||||
boneMapping = 'auto',
|
||||
customBoneMap,
|
||||
preserveRotation = true,
|
||||
preserveLocation = false,
|
||||
} = options;
|
||||
|
||||
// 본 매핑 생성
|
||||
let boneMap: Record<string, string>;
|
||||
|
||||
if (boneMapping === 'custom' && customBoneMap) {
|
||||
boneMap = customBoneMap;
|
||||
} else if (boneMapping === 'auto') {
|
||||
console.log('🔍 Auto-detecting bone mapping...');
|
||||
boneMap = await this.autoMapBones(sourceArmature, targetArmature);
|
||||
console.log(`✅ Mapped ${Object.keys(boneMap).length} bones`);
|
||||
} else {
|
||||
// 미리 정의된 프리셋 사용
|
||||
boneMap = await this.client.sendCommand<Record<string, string>>(
|
||||
'Retargeting.getPresetMapping',
|
||||
{
|
||||
preset: boneMapping,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 본 매핑 검증
|
||||
if (!boneMap || Object.keys(boneMap).length === 0) {
|
||||
throw new Error('Bone mapping is empty. Cannot proceed with retargeting.');
|
||||
}
|
||||
|
||||
// 리타게팅 실행
|
||||
console.log('🎬 Starting animation retargeting...');
|
||||
console.log(` Mapping ${Object.keys(boneMap).length} bones...`);
|
||||
|
||||
await this.client.sendCommand(
|
||||
'Retargeting.retargetAnimation',
|
||||
{
|
||||
sourceArmature,
|
||||
targetArmature,
|
||||
boneMap,
|
||||
preserveRotation,
|
||||
preserveLocation,
|
||||
},
|
||||
TIMING.RETARGET_TIMEOUT
|
||||
);
|
||||
|
||||
console.log('✅ Animation retargeted successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* NLA(Non-Linear Animation) 트랙에 애니메이션 추가
|
||||
*/
|
||||
async addToNLA(
|
||||
armatureName: string,
|
||||
actionName: string,
|
||||
trackName?: string
|
||||
): Promise<void> {
|
||||
await this.client.sendCommand('Animation.addToNLA', {
|
||||
armatureName,
|
||||
actionName,
|
||||
trackName: trackName || `Mixamo_${Date.now()}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 애니메이션 클립 목록 가져오기
|
||||
*/
|
||||
async getAnimations(armatureName: string): Promise<string[]> {
|
||||
return await this.client.sendCommand<string[]>('Animation.list', {
|
||||
armatureName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 애니메이션 미리보기 재생
|
||||
*/
|
||||
async playAnimation(
|
||||
armatureName: string,
|
||||
actionName: string,
|
||||
loop: boolean = true
|
||||
): Promise<void> {
|
||||
await this.client.sendCommand('Animation.play', {
|
||||
armatureName,
|
||||
actionName,
|
||||
loop,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 애니메이션 정지
|
||||
*/
|
||||
async stopAnimation(): Promise<void> {
|
||||
await this.client.sendCommand('Animation.stop');
|
||||
}
|
||||
}
|
||||
|
||||
// BlenderClient에 timeout 파라미터 추가를 위한 타입 확장
|
||||
declare module './client' {
|
||||
interface BlenderClient {
|
||||
sendCommand<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
timeout?: number
|
||||
): Promise<T>;
|
||||
}
|
||||
}
|
||||
35
skills/scripts/src/cli/cli.ts
Normal file
35
skills/scripts/src/cli/cli.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Blender Toolkit CLI - Blender automation command-line interface
|
||||
* Provides geometry creation, object manipulation, and animation retargeting
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { registerGeometryCommands } from './commands/geometry';
|
||||
import { registerObjectCommands } from './commands/object';
|
||||
import { registerModifierCommands } from './commands/modifier';
|
||||
import { registerRetargetingCommands } from './commands/retargeting';
|
||||
import { registerMaterialCommands } from './commands/material';
|
||||
import { registerCollectionCommands } from './commands/collection';
|
||||
import { registerDaemonCommands } from './commands/daemon';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('blender-toolkit')
|
||||
.description('Blender automation CLI with geometry creation, materials, modifiers, collections, and animation retargeting')
|
||||
.version('1.3.0')
|
||||
.addHelpText('after', '\nTip: Use "<command> --help" to see detailed options for each command.\nExample: blender-toolkit material create --help');
|
||||
|
||||
// Register all command groups
|
||||
registerGeometryCommands(program);
|
||||
registerObjectCommands(program);
|
||||
registerModifierCommands(program);
|
||||
registerMaterialCommands(program);
|
||||
registerCollectionCommands(program);
|
||||
registerRetargetingCommands(program);
|
||||
registerDaemonCommands(program);
|
||||
|
||||
// Parse command line arguments
|
||||
program.parse();
|
||||
119
skills/scripts/src/cli/commands/collection.ts
Normal file
119
skills/scripts/src/cli/commands/collection.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Collection CLI Commands
|
||||
* 컬렉션 생성, 오브젝트 추가/제거 등의 CLI 명령
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { BlenderClient } from '../../blender/client';
|
||||
|
||||
export function registerCollectionCommands(program: Command): void {
|
||||
const collectionGroup = program
|
||||
.command('collection')
|
||||
.description('Collection management commands');
|
||||
|
||||
// Create collection
|
||||
collectionGroup
|
||||
.command('create')
|
||||
.description('Create a new collection')
|
||||
.requiredOption('--name <name>', 'Collection name')
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Collection.create', {
|
||||
name: options.name
|
||||
});
|
||||
console.log('✅ Collection created:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// List collections
|
||||
collectionGroup
|
||||
.command('list')
|
||||
.description('List all collections')
|
||||
.action(async () => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Collection.list', {});
|
||||
console.log('📋 Collections:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Add object to collection
|
||||
collectionGroup
|
||||
.command('add-object')
|
||||
.description('Add object to collection')
|
||||
.requiredOption('--object <name>', 'Object name')
|
||||
.requiredOption('--collection <name>', 'Collection name')
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Collection.addObject', {
|
||||
objectName: options.object,
|
||||
collectionName: options.collection
|
||||
});
|
||||
console.log('✅ Object added to collection:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Remove object from collection
|
||||
collectionGroup
|
||||
.command('remove-object')
|
||||
.description('Remove object from collection')
|
||||
.requiredOption('--object <name>', 'Object name')
|
||||
.requiredOption('--collection <name>', 'Collection name')
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Collection.removeObject', {
|
||||
objectName: options.object,
|
||||
collectionName: options.collection
|
||||
});
|
||||
console.log('✅ Object removed from collection:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Delete collection
|
||||
collectionGroup
|
||||
.command('delete')
|
||||
.description('Delete a collection')
|
||||
.requiredOption('--name <name>', 'Collection name')
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Collection.delete', {
|
||||
name: options.name
|
||||
});
|
||||
console.log('✅ Collection deleted:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
196
skills/scripts/src/cli/commands/daemon.ts
Normal file
196
skills/scripts/src/cli/commands/daemon.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Daemon management commands
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { DaemonManager } from '../../daemon/manager';
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
export function registerDaemonCommands(program: Command) {
|
||||
// Start daemon
|
||||
program
|
||||
.command('daemon-start')
|
||||
.description('Start Blender Toolkit daemon (persistent background service)')
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.action(async (options) => {
|
||||
const manager = new DaemonManager();
|
||||
try {
|
||||
await manager.start({ verbose: !options.quiet });
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Stop daemon
|
||||
program
|
||||
.command('daemon-stop')
|
||||
.description('Stop Blender Toolkit daemon')
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.option('-f, --force', 'Force kill the daemon')
|
||||
.action(async (options) => {
|
||||
const manager = new DaemonManager();
|
||||
try {
|
||||
await manager.stop({ verbose: !options.quiet, force: options.force });
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Restart daemon
|
||||
program
|
||||
.command('daemon-restart')
|
||||
.description('Restart Blender Toolkit daemon')
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.action(async (options) => {
|
||||
const manager = new DaemonManager();
|
||||
try {
|
||||
await manager.restart({ verbose: !options.quiet });
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Daemon status
|
||||
program
|
||||
.command('daemon-status')
|
||||
.description('Check daemon status and Blender connection info')
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.action(async (options) => {
|
||||
const manager = new DaemonManager();
|
||||
try {
|
||||
const state = await manager.getStatus({ verbose: !options.quiet });
|
||||
process.exit(state ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Addon install
|
||||
program
|
||||
.command('addon-install')
|
||||
.description('Install Blender Toolkit addon automatically')
|
||||
.option('-b, --blender <path>', 'Blender executable path', 'blender')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
console.log('🔧 Installing Blender Toolkit addon...\n');
|
||||
|
||||
// Install script path
|
||||
const scriptDir = join(__dirname, '..', '..', '..');
|
||||
const installScript = join(scriptDir, 'install-addon.py');
|
||||
|
||||
if (!existsSync(installScript)) {
|
||||
console.error(`❌ Error: Install script not found at ${installScript}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`📍 Script: ${installScript}`);
|
||||
console.log(`📍 Blender: ${options.blender}\n`);
|
||||
|
||||
// Run Blender in background with install script
|
||||
const blender = spawn(options.blender, [
|
||||
'--background',
|
||||
'--python', installScript
|
||||
], {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
blender.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('\n✅ Addon installation completed!');
|
||||
console.log('\n📝 Next steps:');
|
||||
console.log(' 1. Start Blender normally');
|
||||
console.log(' 2. The WebSocket server will auto-start on port 9400');
|
||||
console.log(' 3. Start daemon: blender-toolkit daemon-start');
|
||||
console.log(' 4. Use CLI commands: blender-toolkit <command>');
|
||||
} else {
|
||||
console.error(`\n❌ Installation failed with code ${code}`);
|
||||
}
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
blender.on('error', (error) => {
|
||||
console.error(`\n❌ Failed to run Blender: ${error.message}`);
|
||||
console.error('\nTips:');
|
||||
console.error(' - Make sure Blender is installed');
|
||||
console.error(' - Use --blender flag to specify path: --blender /path/to/blender');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Addon build
|
||||
program
|
||||
.command('addon-build')
|
||||
.description('Build Blender addon ZIP package for distribution')
|
||||
.option('-o, --output-dir <path>', 'Output directory for ZIP file')
|
||||
.option('-f, --force', 'Force rebuild even if ZIP already exists')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
console.log('📦 Building Blender addon ZIP...\n');
|
||||
|
||||
// Build script path (plugins/blender-toolkit/scripts/build-addon.js)
|
||||
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
if (!pluginRoot) {
|
||||
console.error('❌ Error: CLAUDE_PLUGIN_ROOT environment variable not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const buildScript = join(pluginRoot, 'scripts', 'build-addon.js');
|
||||
if (!existsSync(buildScript)) {
|
||||
console.error(`❌ Error: Build script not found at ${buildScript}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||
console.log(`📍 Project: ${projectRoot}`);
|
||||
console.log(`📍 Script: ${buildScript}\n`);
|
||||
|
||||
// Prepare arguments
|
||||
const args = ['--project-root', projectRoot];
|
||||
if (options.outputDir) {
|
||||
args.push('--output-dir', options.outputDir);
|
||||
}
|
||||
if (options.force) {
|
||||
args.push('--force');
|
||||
}
|
||||
|
||||
// Run build script
|
||||
const buildProcess = spawn('node', [buildScript, ...args], {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
buildProcess.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('\n📝 Next steps:');
|
||||
console.log(' 1. Open Blender 4.0+');
|
||||
console.log(' 2. Edit > Preferences > Add-ons > Install');
|
||||
console.log(' 3. Select: .blender-toolkit/blender-toolkit-addon-v*.zip');
|
||||
console.log(' 4. Enable "Blender Toolkit WebSocket Server"');
|
||||
}
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
buildProcess.on('error', (error) => {
|
||||
console.error(`\n❌ Failed to run build script: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
338
skills/scripts/src/cli/commands/geometry.ts
Normal file
338
skills/scripts/src/cli/commands/geometry.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Geometry Commands
|
||||
* Blender 도형 생성 및 메쉬 편집 명령
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { BlenderClient } from '../../blender/client';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const client = new BlenderClient();
|
||||
|
||||
export function registerGeometryCommands(program: Command) {
|
||||
// Create Cube
|
||||
program
|
||||
.command('create-cube')
|
||||
.description('Create a cube primitive')
|
||||
.option('-x, --x <number>', 'X position', parseFloat, 0)
|
||||
.option('-y, --y <number>', 'Y position', parseFloat, 0)
|
||||
.option('-z, --z <number>', 'Z position', parseFloat, 0)
|
||||
.option('-s, --size <number>', 'Cube size', parseFloat, 2.0)
|
||||
.option('-n, --name <string>', 'Object name')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Geometry.createCube', {
|
||||
location: [options.x, options.y, options.z],
|
||||
size: options.size,
|
||||
name: options.name
|
||||
});
|
||||
|
||||
console.log('✅ Cube created successfully:');
|
||||
console.log(` Name: ${result.name}`);
|
||||
console.log(` Location: [${result.location.join(', ')}]`);
|
||||
console.log(` Vertices: ${result.vertices}`);
|
||||
console.log(` Faces: ${result.faces}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to create cube:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Create Sphere
|
||||
program
|
||||
.command('create-sphere')
|
||||
.description('Create a sphere primitive')
|
||||
.option('-x, --x <number>', 'X position', parseFloat, 0)
|
||||
.option('-y, --y <number>', 'Y position', parseFloat, 0)
|
||||
.option('-z, --z <number>', 'Z position', parseFloat, 0)
|
||||
.option('-r, --radius <number>', 'Sphere radius', parseFloat, 1.0)
|
||||
.option('--segments <number>', 'Number of segments', parseInt, 32)
|
||||
.option('--rings <number>', 'Number of rings', parseInt, 16)
|
||||
.option('-n, --name <string>', 'Object name')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Geometry.createSphere', {
|
||||
location: [options.x, options.y, options.z],
|
||||
radius: options.radius,
|
||||
segments: options.segments,
|
||||
ringCount: options.rings,
|
||||
name: options.name
|
||||
});
|
||||
|
||||
console.log('✅ Sphere created successfully:');
|
||||
console.log(` Name: ${result.name}`);
|
||||
console.log(` Location: [${result.location.join(', ')}]`);
|
||||
console.log(` Vertices: ${result.vertices}`);
|
||||
console.log(` Faces: ${result.faces}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to create sphere:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Create Cylinder
|
||||
program
|
||||
.command('create-cylinder')
|
||||
.description('Create a cylinder primitive')
|
||||
.option('-x, --x <number>', 'X position', parseFloat, 0)
|
||||
.option('-y, --y <number>', 'Y position', parseFloat, 0)
|
||||
.option('-z, --z <number>', 'Z position', parseFloat, 0)
|
||||
.option('-r, --radius <number>', 'Cylinder radius', parseFloat, 1.0)
|
||||
.option('-d, --depth <number>', 'Cylinder height/depth', parseFloat, 2.0)
|
||||
.option('--vertices <number>', 'Number of vertices', parseInt, 32)
|
||||
.option('-n, --name <string>', 'Object name')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Geometry.createCylinder', {
|
||||
location: [options.x, options.y, options.z],
|
||||
radius: options.radius,
|
||||
depth: options.depth,
|
||||
vertices: options.vertices,
|
||||
name: options.name
|
||||
});
|
||||
|
||||
console.log('✅ Cylinder created successfully:');
|
||||
console.log(` Name: ${result.name}`);
|
||||
console.log(` Location: [${result.location.join(', ')}]`);
|
||||
console.log(` Vertices: ${result.vertices}`);
|
||||
console.log(` Faces: ${result.faces}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to create cylinder:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Create Plane
|
||||
program
|
||||
.command('create-plane')
|
||||
.description('Create a plane primitive')
|
||||
.option('-x, --x <number>', 'X position', parseFloat, 0)
|
||||
.option('-y, --y <number>', 'Y position', parseFloat, 0)
|
||||
.option('-z, --z <number>', 'Z position', parseFloat, 0)
|
||||
.option('-s, --size <number>', 'Plane size', parseFloat, 2.0)
|
||||
.option('-n, --name <string>', 'Object name')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Geometry.createPlane', {
|
||||
location: [options.x, options.y, options.z],
|
||||
size: options.size,
|
||||
name: options.name
|
||||
});
|
||||
|
||||
console.log('✅ Plane created successfully:');
|
||||
console.log(` Name: ${result.name}`);
|
||||
console.log(` Location: [${result.location.join(', ')}]`);
|
||||
console.log(` Vertices: ${result.vertices}`);
|
||||
console.log(` Faces: ${result.faces}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to create plane:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Create Cone
|
||||
program
|
||||
.command('create-cone')
|
||||
.description('Create a cone primitive')
|
||||
.option('-x, --x <number>', 'X position', parseFloat, 0)
|
||||
.option('-y, --y <number>', 'Y position', parseFloat, 0)
|
||||
.option('-z, --z <number>', 'Z position', parseFloat, 0)
|
||||
.option('-r, --radius <number>', 'Cone base radius', parseFloat, 1.0)
|
||||
.option('-d, --depth <number>', 'Cone height/depth', parseFloat, 2.0)
|
||||
.option('--vertices <number>', 'Number of vertices', parseInt, 32)
|
||||
.option('-n, --name <string>', 'Object name')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Geometry.createCone', {
|
||||
location: [options.x, options.y, options.z],
|
||||
radius1: options.radius,
|
||||
depth: options.depth,
|
||||
vertices: options.vertices,
|
||||
name: options.name
|
||||
});
|
||||
|
||||
console.log('✅ Cone created successfully:');
|
||||
console.log(` Name: ${result.name}`);
|
||||
console.log(` Location: [${result.location.join(', ')}]`);
|
||||
console.log(` Vertices: ${result.vertices}`);
|
||||
console.log(` Faces: ${result.faces}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to create cone:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Create Torus
|
||||
program
|
||||
.command('create-torus')
|
||||
.description('Create a torus primitive')
|
||||
.option('-x, --x <number>', 'X position', parseFloat, 0)
|
||||
.option('-y, --y <number>', 'Y position', parseFloat, 0)
|
||||
.option('-z, --z <number>', 'Z position', parseFloat, 0)
|
||||
.option('--major-radius <number>', 'Major radius', parseFloat, 1.0)
|
||||
.option('--minor-radius <number>', 'Minor radius (tube thickness)', parseFloat, 0.25)
|
||||
.option('--major-segments <number>', 'Major segments', parseInt, 48)
|
||||
.option('--minor-segments <number>', 'Minor segments', parseInt, 12)
|
||||
.option('-n, --name <string>', 'Object name')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Geometry.createTorus', {
|
||||
location: [options.x, options.y, options.z],
|
||||
majorRadius: options.majorRadius,
|
||||
minorRadius: options.minorRadius,
|
||||
majorSegments: options.majorSegments,
|
||||
minorSegments: options.minorSegments,
|
||||
name: options.name
|
||||
});
|
||||
|
||||
console.log('✅ Torus created successfully:');
|
||||
console.log(` Name: ${result.name}`);
|
||||
console.log(` Location: [${result.location.join(', ')}]`);
|
||||
console.log(` Vertices: ${result.vertices}`);
|
||||
console.log(` Faces: ${result.faces}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to create torus:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Subdivide Mesh
|
||||
program
|
||||
.command('subdivide')
|
||||
.description('Subdivide a mesh object')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.option('-c, --cuts <number>', 'Number of subdivision cuts', parseInt, 1)
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Geometry.subdivideMesh', {
|
||||
name: options.name,
|
||||
cuts: options.cuts
|
||||
});
|
||||
|
||||
console.log('✅ Mesh subdivided successfully:');
|
||||
console.log(` Name: ${result.name}`);
|
||||
console.log(` Vertices: ${result.vertices}`);
|
||||
console.log(` Edges: ${result.edges}`);
|
||||
console.log(` Faces: ${result.faces}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to subdivide mesh:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get Vertices
|
||||
program
|
||||
.command('get-vertices')
|
||||
.description('Get vertices information of an object')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const vertices: any = await client.sendCommand('Geometry.getVertices', {
|
||||
name: options.name
|
||||
});
|
||||
|
||||
console.log(`✅ Found ${vertices.length} vertices in "${options.name}":`);
|
||||
|
||||
if (vertices.length <= 10) {
|
||||
// Show all vertices if 10 or less
|
||||
vertices.forEach((v: any) => {
|
||||
console.log(` Vertex ${v.index}: [${v.co.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||
});
|
||||
} else {
|
||||
// Show first 5 and last 5 if more than 10
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const v = vertices[i];
|
||||
console.log(` Vertex ${v.index}: [${v.co.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||
}
|
||||
console.log(` ... (${vertices.length - 10} more vertices)`);
|
||||
for (let i = vertices.length - 5; i < vertices.length; i++) {
|
||||
const v = vertices[i];
|
||||
console.log(` Vertex ${v.index}: [${v.co.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||
}
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to get vertices:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Move Vertex
|
||||
program
|
||||
.command('move-vertex')
|
||||
.description('Move a specific vertex to a new position')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.requiredOption('-i, --index <number>', 'Vertex index', parseInt)
|
||||
.requiredOption('-x, --x <number>', 'New X position', parseFloat)
|
||||
.requiredOption('-y, --y <number>', 'New Y position', parseFloat)
|
||||
.requiredOption('-z, --z <number>', 'New Z position', parseFloat)
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Geometry.moveVertex', {
|
||||
objectName: options.name,
|
||||
vertexIndex: options.index,
|
||||
newPosition: [options.x, options.y, options.z]
|
||||
});
|
||||
|
||||
console.log('✅ Vertex moved successfully:');
|
||||
console.log(` Object: ${result.object}`);
|
||||
console.log(` Vertex ${result.vertex_index}: [${result.position.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to move vertex:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
241
skills/scripts/src/cli/commands/material.ts
Normal file
241
skills/scripts/src/cli/commands/material.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Material CLI Commands
|
||||
* 머티리얼 생성, 할당, 속성 설정 등의 CLI 명령
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { BlenderClient } from '../../blender/client';
|
||||
|
||||
export function registerMaterialCommands(program: Command): void {
|
||||
const materialGroup = program
|
||||
.command('material')
|
||||
.description('Material creation and management commands');
|
||||
|
||||
// Create material
|
||||
materialGroup
|
||||
.command('create')
|
||||
.description('Create a new material')
|
||||
.requiredOption('--name <name>', 'Material name')
|
||||
.option('--no-nodes', 'Disable node-based material (default: enabled)')
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Material.create', {
|
||||
name: options.name,
|
||||
useNodes: options.nodes
|
||||
});
|
||||
console.log('✅ Material created:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// List materials
|
||||
materialGroup
|
||||
.command('list')
|
||||
.description('List all materials')
|
||||
.action(async () => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Material.list', {});
|
||||
console.log('📋 Materials:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Delete material
|
||||
materialGroup
|
||||
.command('delete')
|
||||
.description('Delete a material')
|
||||
.requiredOption('--name <name>', 'Material name')
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Material.delete', {
|
||||
name: options.name
|
||||
});
|
||||
console.log('✅ Material deleted:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Assign material to object
|
||||
materialGroup
|
||||
.command('assign')
|
||||
.description('Assign material to object')
|
||||
.requiredOption('--object <name>', 'Object name')
|
||||
.requiredOption('--material <name>', 'Material name')
|
||||
.option('--slot <index>', 'Material slot index', '0')
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Material.assign', {
|
||||
objectName: options.object,
|
||||
materialName: options.material,
|
||||
slotIndex: parseInt(options.slot)
|
||||
});
|
||||
console.log('✅ Material assigned:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// List object materials
|
||||
materialGroup
|
||||
.command('list-object')
|
||||
.description('List materials of an object')
|
||||
.requiredOption('--object <name>', 'Object name')
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Material.listObjectMaterials', {
|
||||
objectName: options.object
|
||||
});
|
||||
console.log('📋 Object materials:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Set base color
|
||||
materialGroup
|
||||
.command('set-color')
|
||||
.description('Set material base color')
|
||||
.requiredOption('--material <name>', 'Material name')
|
||||
.requiredOption('--r <value>', 'Red (0-1)', parseFloat)
|
||||
.requiredOption('--g <value>', 'Green (0-1)', parseFloat)
|
||||
.requiredOption('--b <value>', 'Blue (0-1)', parseFloat)
|
||||
.option('--a <value>', 'Alpha (0-1)', parseFloat, 1.0)
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Material.setBaseColor', {
|
||||
materialName: options.material,
|
||||
color: [options.r, options.g, options.b, options.a]
|
||||
});
|
||||
console.log('✅ Base color set:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Set metallic
|
||||
materialGroup
|
||||
.command('set-metallic')
|
||||
.description('Set material metallic value')
|
||||
.requiredOption('--material <name>', 'Material name')
|
||||
.requiredOption('--value <value>', 'Metallic value (0-1)', parseFloat)
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Material.setMetallic', {
|
||||
materialName: options.material,
|
||||
metallic: options.value
|
||||
});
|
||||
console.log('✅ Metallic set:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Set roughness
|
||||
materialGroup
|
||||
.command('set-roughness')
|
||||
.description('Set material roughness value')
|
||||
.requiredOption('--material <name>', 'Material name')
|
||||
.requiredOption('--value <value>', 'Roughness value (0-1)', parseFloat)
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Material.setRoughness', {
|
||||
materialName: options.material,
|
||||
roughness: options.value
|
||||
});
|
||||
console.log('✅ Roughness set:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Set emission
|
||||
materialGroup
|
||||
.command('set-emission')
|
||||
.description('Set material emission')
|
||||
.requiredOption('--material <name>', 'Material name')
|
||||
.requiredOption('--r <value>', 'Red (0-1)', parseFloat)
|
||||
.requiredOption('--g <value>', 'Green (0-1)', parseFloat)
|
||||
.requiredOption('--b <value>', 'Blue (0-1)', parseFloat)
|
||||
.option('--strength <value>', 'Emission strength', parseFloat, 1.0)
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Material.setEmission', {
|
||||
materialName: options.material,
|
||||
color: [options.r, options.g, options.b, 1.0],
|
||||
strength: options.strength
|
||||
});
|
||||
console.log('✅ Emission set:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Get material properties
|
||||
materialGroup
|
||||
.command('get-properties')
|
||||
.description('Get material properties')
|
||||
.requiredOption('--material <name>', 'Material name')
|
||||
.action(async (options) => {
|
||||
const client = new BlenderClient();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.sendCommand('Material.getProperties', {
|
||||
materialName: options.material
|
||||
});
|
||||
console.log('📋 Material properties:', JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
278
skills/scripts/src/cli/commands/modifier.ts
Normal file
278
skills/scripts/src/cli/commands/modifier.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Modifier Commands
|
||||
* Blender 모디파이어 명령
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { BlenderClient } from '../../blender/client';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const client = new BlenderClient();
|
||||
|
||||
export function registerModifierCommands(program: Command) {
|
||||
// Add Modifier
|
||||
program
|
||||
.command('add-modifier')
|
||||
.description('Add a modifier to an object')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.requiredOption('-t, --type <string>', 'Modifier type (SUBSURF, MIRROR, ARRAY, BEVEL, etc.)')
|
||||
.option('--mod-name <string>', 'Modifier name')
|
||||
.option('--levels <number>', 'Subdivision levels (for SUBSURF)', parseInt)
|
||||
.option('--render-levels <number>', 'Render levels (for SUBSURF)', parseInt)
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const properties: any = {};
|
||||
|
||||
if (options.levels !== undefined) {
|
||||
properties.levels = options.levels;
|
||||
}
|
||||
|
||||
if (options.renderLevels !== undefined) {
|
||||
properties.render_levels = options.renderLevels;
|
||||
}
|
||||
|
||||
const result: any = await client.sendCommand('Modifier.add', {
|
||||
objectName: options.name,
|
||||
modifierType: options.type,
|
||||
name: options.modName,
|
||||
properties
|
||||
});
|
||||
|
||||
console.log('✅ Modifier added successfully:');
|
||||
console.log(` Object: ${result.object}`);
|
||||
console.log(` Modifier: ${result.modifier} (${result.type})`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to add modifier:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply Modifier
|
||||
program
|
||||
.command('apply-modifier')
|
||||
.description('Apply a modifier to an object')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.requiredOption('-m, --modifier <string>', 'Modifier name')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Modifier.apply', {
|
||||
objectName: options.name,
|
||||
modifierName: options.modifier
|
||||
});
|
||||
|
||||
console.log(`✅ ${result.message}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply modifier:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// List Modifiers
|
||||
program
|
||||
.command('list-modifiers')
|
||||
.description('List all modifiers on an object')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Modifier.list', {
|
||||
objectName: options.name
|
||||
});
|
||||
|
||||
console.log('📋 Modifiers:');
|
||||
if (result.length === 0) {
|
||||
console.log(' No modifiers found');
|
||||
} else {
|
||||
result.forEach((mod: any) => {
|
||||
console.log(` - ${mod.name} (${mod.type})`);
|
||||
console.log(` Viewport: ${mod.show_viewport}, Render: ${mod.show_render}`);
|
||||
if (mod.levels !== undefined) {
|
||||
console.log(` Levels: ${mod.levels}, Render Levels: ${mod.render_levels}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to list modifiers:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove Modifier
|
||||
program
|
||||
.command('remove-modifier')
|
||||
.description('Remove a modifier from an object')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.requiredOption('-m, --modifier <string>', 'Modifier name')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Modifier.remove', {
|
||||
objectName: options.name,
|
||||
modifierName: options.modifier
|
||||
});
|
||||
|
||||
console.log(`✅ ${result.message}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove modifier:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle Modifier
|
||||
program
|
||||
.command('toggle-modifier')
|
||||
.description('Toggle modifier visibility')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.requiredOption('-m, --modifier <string>', 'Modifier name')
|
||||
.option('--viewport <boolean>', 'Viewport visibility (true/false)')
|
||||
.option('--render <boolean>', 'Render visibility (true/false)')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const params: any = {
|
||||
objectName: options.name,
|
||||
modifierName: options.modifier
|
||||
};
|
||||
|
||||
if (options.viewport !== undefined) {
|
||||
params.viewport = options.viewport === 'true';
|
||||
}
|
||||
if (options.render !== undefined) {
|
||||
params.render = options.render === 'true';
|
||||
}
|
||||
|
||||
const result: any = await client.sendCommand('Modifier.toggle', params);
|
||||
|
||||
console.log('✅ Modifier toggled:');
|
||||
console.log(` Viewport: ${result.show_viewport}`);
|
||||
console.log(` Render: ${result.show_render}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle modifier:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Modify Modifier Properties
|
||||
program
|
||||
.command('modify-modifier')
|
||||
.description('Modify modifier properties')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.requiredOption('-m, --modifier <string>', 'Modifier name')
|
||||
.option('--levels <number>', 'Subdivision levels', parseInt)
|
||||
.option('--render-levels <number>', 'Render levels', parseInt)
|
||||
.option('--width <number>', 'Bevel width', parseFloat)
|
||||
.option('--segments <number>', 'Bevel segments', parseInt)
|
||||
.option('--count <number>', 'Array count', parseInt)
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const properties: any = {};
|
||||
|
||||
if (options.levels !== undefined) properties.levels = options.levels;
|
||||
if (options.renderLevels !== undefined) properties.render_levels = options.renderLevels;
|
||||
if (options.width !== undefined) properties.width = options.width;
|
||||
if (options.segments !== undefined) properties.segments = options.segments;
|
||||
if (options.count !== undefined) properties.count = options.count;
|
||||
|
||||
const result: any = await client.sendCommand('Modifier.modify', {
|
||||
objectName: options.name,
|
||||
modifierName: options.modifier,
|
||||
properties
|
||||
});
|
||||
|
||||
console.log('✅ Modifier properties updated:');
|
||||
console.log(` Updated properties: ${result.updated_properties ? Object.keys(result.updated_properties).join(', ') : 'none'}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to modify modifier properties:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get Modifier Info
|
||||
program
|
||||
.command('get-modifier-info')
|
||||
.description('Get detailed modifier information')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.requiredOption('-m, --modifier <string>', 'Modifier name')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Modifier.getInfo', {
|
||||
objectName: options.name,
|
||||
modifierName: options.modifier
|
||||
});
|
||||
|
||||
console.log('📋 Modifier Info:');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to get modifier info:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Reorder Modifier
|
||||
program
|
||||
.command('reorder-modifier')
|
||||
.description('Reorder modifier in stack')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.requiredOption('-m, --modifier <string>', 'Modifier name')
|
||||
.requiredOption('-d, --direction <string>', 'Direction (UP or DOWN)')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Modifier.reorder', {
|
||||
objectName: options.name,
|
||||
modifierName: options.modifier,
|
||||
direction: options.direction.toUpperCase()
|
||||
});
|
||||
|
||||
console.log(`✅ Modifier reordered`);
|
||||
console.log(` New order: ${result.new_order.join(' > ')}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to reorder modifier:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
176
skills/scripts/src/cli/commands/object.ts
Normal file
176
skills/scripts/src/cli/commands/object.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Object Commands
|
||||
* Blender 오브젝트 조작 명령
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { BlenderClient } from '../../blender/client';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const client = new BlenderClient();
|
||||
|
||||
export function registerObjectCommands(program: Command) {
|
||||
// List Objects
|
||||
program
|
||||
.command('list-objects')
|
||||
.description('List all objects in the scene')
|
||||
.option('-t, --type <string>', 'Filter by object type (MESH, ARMATURE, CAMERA, LIGHT)')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const objects: any = await client.sendCommand('Object.list', {
|
||||
type: options.type
|
||||
});
|
||||
|
||||
if (objects.length === 0) {
|
||||
console.log('No objects found in the scene.');
|
||||
} else {
|
||||
console.log(`✅ Found ${objects.length} object(s):\n`);
|
||||
|
||||
objects.forEach((obj: any) => {
|
||||
console.log(`📦 ${obj.name} (${obj.type})`);
|
||||
console.log(` Location: [${obj.location.map((n: number) => n.toFixed(2)).join(', ')}]`);
|
||||
console.log(` Rotation: [${obj.rotation.map((n: number) => n.toFixed(2)).join(', ')}]`);
|
||||
console.log(` Scale: [${obj.scale.map((n: number) => n.toFixed(2)).join(', ')}]`);
|
||||
console.log('');
|
||||
});
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to list objects:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform Object
|
||||
program
|
||||
.command('transform')
|
||||
.description('Transform an object (move, rotate, scale)')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.option('--loc-x <number>', 'X location', parseFloat)
|
||||
.option('--loc-y <number>', 'Y location', parseFloat)
|
||||
.option('--loc-z <number>', 'Z location', parseFloat)
|
||||
.option('--rot-x <number>', 'X rotation (radians)', parseFloat)
|
||||
.option('--rot-y <number>', 'Y rotation (radians)', parseFloat)
|
||||
.option('--rot-z <number>', 'Z rotation (radians)', parseFloat)
|
||||
.option('--scale-x <number>', 'X scale', parseFloat)
|
||||
.option('--scale-y <number>', 'Y scale', parseFloat)
|
||||
.option('--scale-z <number>', 'Z scale', parseFloat)
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const params: any = { name: options.name };
|
||||
|
||||
if (options.locX !== undefined || options.locY !== undefined || options.locZ !== undefined) {
|
||||
params.location = [
|
||||
options.locX ?? 0,
|
||||
options.locY ?? 0,
|
||||
options.locZ ?? 0
|
||||
];
|
||||
}
|
||||
|
||||
if (options.rotX !== undefined || options.rotY !== undefined || options.rotZ !== undefined) {
|
||||
params.rotation = [
|
||||
options.rotX ?? 0,
|
||||
options.rotY ?? 0,
|
||||
options.rotZ ?? 0
|
||||
];
|
||||
}
|
||||
|
||||
if (options.scaleX !== undefined || options.scaleY !== undefined || options.scaleZ !== undefined) {
|
||||
params.scale = [
|
||||
options.scaleX ?? 1,
|
||||
options.scaleY ?? 1,
|
||||
options.scaleZ ?? 1
|
||||
];
|
||||
}
|
||||
|
||||
const result: any = await client.sendCommand('Object.transform', params);
|
||||
|
||||
console.log('✅ Object transformed successfully:');
|
||||
console.log(` Name: ${result.name}`);
|
||||
console.log(` Location: [${result.location.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||
console.log(` Rotation: [${result.rotation.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||
console.log(` Scale: [${result.scale.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to transform object:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Duplicate Object
|
||||
program
|
||||
.command('duplicate')
|
||||
.description('Duplicate an object')
|
||||
.requiredOption('-n, --name <string>', 'Source object name')
|
||||
.option('--new-name <string>', 'New object name')
|
||||
.option('-x, --x <number>', 'X position for duplicate', parseFloat)
|
||||
.option('-y, --y <number>', 'Y position for duplicate', parseFloat)
|
||||
.option('-z, --z <number>', 'Z position for duplicate', parseFloat)
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const params: any = { name: options.name };
|
||||
|
||||
if (options.newName) {
|
||||
params.newName = options.newName;
|
||||
}
|
||||
|
||||
if (options.x !== undefined || options.y !== undefined || options.z !== undefined) {
|
||||
params.location = [
|
||||
options.x ?? 0,
|
||||
options.y ?? 0,
|
||||
options.z ?? 0
|
||||
];
|
||||
}
|
||||
|
||||
const result: any = await client.sendCommand('Object.duplicate', params);
|
||||
|
||||
console.log('✅ Object duplicated successfully:');
|
||||
console.log(` New Name: ${result.name}`);
|
||||
console.log(` Type: ${result.type}`);
|
||||
console.log(` Location: [${result.location.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to duplicate object:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Object
|
||||
program
|
||||
.command('delete')
|
||||
.description('Delete an object')
|
||||
.requiredOption('-n, --name <string>', 'Object name')
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await client.connect(options.port);
|
||||
|
||||
const result: any = await client.sendCommand('Object.delete', {
|
||||
name: options.name
|
||||
});
|
||||
|
||||
console.log(`✅ ${result.message}`);
|
||||
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete object:', error);
|
||||
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
77
skills/scripts/src/cli/commands/retargeting.ts
Normal file
77
skills/scripts/src/cli/commands/retargeting.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Retargeting Commands
|
||||
* Blender 애니메이션 리타게팅 명령
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { AnimationRetargetingWorkflow } from '../../index';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export function registerRetargetingCommands(program: Command) {
|
||||
// Retarget Animation
|
||||
program
|
||||
.command('retarget')
|
||||
.description('Retarget animation from Mixamo to your character')
|
||||
.requiredOption('-t, --target <string>', 'Target character armature name')
|
||||
.requiredOption('-f, --file <string>', 'Animation file path (FBX or DAE)')
|
||||
.option('-n, --name <string>', 'Animation name for NLA track')
|
||||
.option('-m, --mapping <string>', 'Bone mapping mode (auto, mixamo_to_rigify, custom)', 'auto')
|
||||
.option('--skip-confirmation', 'Skip bone mapping confirmation', false)
|
||||
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||
.option('-o, --output <string>', 'Output directory')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const workflow = new AnimationRetargetingWorkflow();
|
||||
|
||||
console.log('🎬 Starting animation retargeting workflow...\n');
|
||||
|
||||
await workflow.run({
|
||||
blenderPort: options.port,
|
||||
targetCharacterArmature: options.target,
|
||||
animationFilePath: options.file,
|
||||
animationName: options.name,
|
||||
boneMapping: options.mapping,
|
||||
skipConfirmation: options.skipConfirmation,
|
||||
outputDir: options.output
|
||||
});
|
||||
|
||||
console.log('\n✅ Animation retargeting completed successfully!');
|
||||
} catch (error) {
|
||||
logger.error('Retargeting failed:', error);
|
||||
console.error('\n❌ Retargeting failed:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Show Mixamo download instructions
|
||||
program
|
||||
.command('mixamo-help')
|
||||
.description('Show Mixamo download instructions and popular animations')
|
||||
.argument('[animation-name]', 'Animation name (optional)')
|
||||
.action((animationName) => {
|
||||
const workflow = new AnimationRetargetingWorkflow();
|
||||
|
||||
if (animationName) {
|
||||
console.log(workflow.getManualDownloadInstructions(animationName));
|
||||
} else {
|
||||
console.log('📚 Popular Mixamo Animations:\n');
|
||||
|
||||
const popularAnimations = workflow.getPopularAnimations();
|
||||
Object.entries(popularAnimations).forEach(([category, animations]) => {
|
||||
console.log(`\n${category}:`);
|
||||
(animations as unknown as string[]).forEach((anim) => {
|
||||
console.log(` • ${anim}`);
|
||||
});
|
||||
});
|
||||
|
||||
console.log('\n\n📥 Download Instructions:\n');
|
||||
console.log(workflow.getManualDownloadInstructions('Walking'));
|
||||
}
|
||||
|
||||
console.log('\n⚙️ Recommended Settings:\n');
|
||||
const settings = workflow.getRecommendedSettings();
|
||||
Object.entries(settings).forEach(([key, value]) => {
|
||||
console.log(` ${key}: ${value}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
135
skills/scripts/src/constants/index.ts
Normal file
135
skills/scripts/src/constants/index.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Blender Toolkit Constants
|
||||
* 모든 매직 넘버, 포트, 타이밍 등을 중앙에서 관리
|
||||
*/
|
||||
|
||||
/**
|
||||
* Blender WebSocket 관련 상수
|
||||
* @property DEFAULT_PORT - 기본 WebSocket 포트 (9400, Browser-Pilot과 충돌 방지)
|
||||
* @property PORT_RANGE_MAX - 포트 검색 범위 (100)
|
||||
* @property LOCALHOST - 로컬 호스트 주소
|
||||
* @property WS_TIMEOUT - WebSocket 연결 타임아웃 (30초)
|
||||
*/
|
||||
export const BLENDER = {
|
||||
DEFAULT_PORT: 9400,
|
||||
PORT_RANGE_MAX: 100,
|
||||
LOCALHOST: '127.0.0.1',
|
||||
WS_TIMEOUT: 30000, // 30 seconds
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 파일 시스템 관련 상수
|
||||
* @property OUTPUT_DIR - 출력 디렉토리 (.blender-toolkit)
|
||||
* @property ANIMATIONS_DIR - 애니메이션 다운로드 디렉토리
|
||||
* @property CONFIG_FILE - 설정 파일명
|
||||
* @property DAEMON_PID_FILE - 데몬 PID 파일명
|
||||
* @property GITIGNORE_CONTENT - .gitignore 기본 내용
|
||||
*/
|
||||
export const FS = {
|
||||
OUTPUT_DIR: '.blender-toolkit',
|
||||
ANIMATIONS_DIR: 'animations',
|
||||
MODELS_DIR: 'models',
|
||||
CONFIG_FILE: 'blender-config.json',
|
||||
DAEMON_PID_FILE: 'daemon.pid',
|
||||
GITIGNORE_CONTENT: `# Blender Toolkit generated files
|
||||
*
|
||||
`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Mixamo 관련 상수
|
||||
* Note: Mixamo does not provide an official API. Users must manually download files from Mixamo.com
|
||||
* @property WEBSITE_URL - Mixamo 웹사이트 URL
|
||||
* @property SUPPORTED_FORMATS - 지원 파일 포맷
|
||||
* @property RECOMMENDED_FORMAT - 권장 다운로드 포맷
|
||||
* @property RECOMMENDED_SKIN - 권장 스킨 설정 (리타게팅용)
|
||||
* @property RECOMMENDED_FPS - 권장 FPS
|
||||
*/
|
||||
export const MIXAMO = {
|
||||
WEBSITE_URL: 'https://www.mixamo.com',
|
||||
SUPPORTED_FORMATS: ['fbx', 'dae'] as const,
|
||||
RECOMMENDED_FORMAT: 'fbx' as const,
|
||||
RECOMMENDED_SKIN: 'Without Skin', // Better for retargeting
|
||||
RECOMMENDED_FPS: 30,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 리타게팅 관련 상수
|
||||
*/
|
||||
export const RETARGETING = {
|
||||
BONE_MAPPING_PRESETS: {
|
||||
MIXAMO_TO_RIGIFY: 'mixamo_to_rigify',
|
||||
MIXAMO_TO_CUSTOM: 'mixamo_to_custom',
|
||||
AUTO_DETECT: 'auto_detect',
|
||||
},
|
||||
CONSTRAINT_TYPES: ['COPY_ROTATION', 'COPY_LOCATION'] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 타이밍 관련 상수 (모든 시간 단위는 밀리초)
|
||||
*/
|
||||
export const TIMING = {
|
||||
DEFAULT_TIMEOUT: 30000, // 30 seconds
|
||||
IMPORT_TIMEOUT: 60000, // 1 minute
|
||||
RETARGET_TIMEOUT: 120000, // 2 minutes
|
||||
RENDER_TIMEOUT: 300000, // 5 minutes
|
||||
POLLING_INTERVAL: 1000, // 1 second
|
||||
DAEMON_IDLE_TIMEOUT: 1800000, // 30 minutes
|
||||
DAEMON_PING_INTERVAL: 5000, // 5 seconds
|
||||
HOOK_INPUT_TIMEOUT: 100, // 100ms for reading stdin
|
||||
ACTION_DELAY_SHORT: 50, // 50ms
|
||||
ACTION_DELAY_MEDIUM: 100, // 100ms
|
||||
ACTION_DELAY_LONG: 500, // 500ms
|
||||
POLLING_INTERVAL_FAST: 100, // 100ms
|
||||
POLLING_INTERVAL_STANDARD: 500, // 500ms
|
||||
POLLING_INTERVAL_SLOW: 1000, // 1s
|
||||
WAIT_FOR_BLENDER: 5000, // 5s - wait for Blender connection
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Daemon 관련 상수
|
||||
*/
|
||||
export const DAEMON = {
|
||||
IPC_TIMEOUT: 5000, // 5 seconds
|
||||
MAX_RETRIES: 3,
|
||||
RETRY_DELAY: 1000, // 1 second
|
||||
IDLE_CHECK_INTERVAL: 60000, // 1 minute
|
||||
MAX_MESSAGE_SIZE: 10 * 1024 * 1024, // 10MB - Browser Pilot 패턴
|
||||
CONNECT_TIMEOUT: 5000, // 5 seconds
|
||||
SHUTDOWN_TIMEOUT: 5000, // 5 seconds for graceful shutdown
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 환경 변수 이름 상수
|
||||
*/
|
||||
export const ENV = {
|
||||
BLENDER_WS_PORT: 'BLENDER_WS_PORT',
|
||||
BLENDER_EXECUTABLE: 'BLENDER_EXECUTABLE',
|
||||
CLAUDE_PROJECT_DIR: 'CLAUDE_PROJECT_DIR',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 에러 메시지
|
||||
*/
|
||||
export const ERROR_MESSAGES = {
|
||||
BLENDER_NOT_RUNNING: 'Blender is not running or WebSocket server is not started',
|
||||
CONNECTION_FAILED: 'Failed to connect to Blender',
|
||||
TIMEOUT: 'Operation timed out',
|
||||
IMPORT_FAILED: 'Failed to import animation',
|
||||
RETARGET_FAILED: 'Failed to retarget animation',
|
||||
NO_CHARACTER_SELECTED: 'No character selected',
|
||||
ANIMATION_FILE_NOT_FOUND: 'Animation file not found. Please download from Mixamo.com first',
|
||||
INVALID_BONE_MAPPING: 'Invalid bone mapping',
|
||||
BONE_MAPPING_CONFIRMATION_FAILED: 'Bone mapping confirmation failed',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 성공 메시지
|
||||
*/
|
||||
export const SUCCESS_MESSAGES = {
|
||||
CONNECTED: 'Connected to Blender',
|
||||
ANIMATION_IMPORTED: 'Animation imported successfully',
|
||||
BONE_MAPPING_GENERATED: 'Bone mapping generated successfully',
|
||||
BONE_MAPPING_SENT_TO_UI: 'Bone mapping sent to Blender UI for review',
|
||||
RETARGETING_COMPLETE: 'Animation retargeted successfully',
|
||||
} as const;
|
||||
209
skills/scripts/src/daemon/client.ts
Normal file
209
skills/scripts/src/daemon/client.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* IPC Client for Blender Toolkit Daemon
|
||||
* Used by CLI commands to communicate with the daemon
|
||||
*/
|
||||
|
||||
import { Socket, connect } from 'net';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getOutputDir } from '../blender/config';
|
||||
import {
|
||||
IPCRequest,
|
||||
IPCResponse,
|
||||
SOCKET_PATH_PREFIX,
|
||||
getProjectSocketName
|
||||
} from './protocol';
|
||||
import { DAEMON } from '../constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export class IPCClient {
|
||||
private socket: Socket | null = null;
|
||||
private socketPath: string;
|
||||
private pendingRequests: Map<string, {
|
||||
resolve: (response: IPCResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}> = new Map();
|
||||
private buffer: string = '';
|
||||
|
||||
constructor() {
|
||||
const outputDir = getOutputDir();
|
||||
this.socketPath = this.getSocketPath(outputDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get socket path (platform-specific, project-unique)
|
||||
*/
|
||||
private getSocketPath(outputDir: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: project-specific named pipe
|
||||
const socketName = getProjectSocketName();
|
||||
return `\\\\.\\pipe\\${socketName}`;
|
||||
} else {
|
||||
// Unix domain socket (already project-specific via outputDir)
|
||||
return join(outputDir, `${SOCKET_PATH_PREFIX}.sock`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to daemon
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.socket && !this.socket.destroyed) {
|
||||
return; // Already connected
|
||||
}
|
||||
|
||||
// Check if socket file exists (Unix only)
|
||||
if (process.platform !== 'win32' && !existsSync(this.socketPath)) {
|
||||
throw new Error('Daemon not running (socket file not found)');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Browser Pilot 패턴: 연결 타임아웃
|
||||
const timeout = setTimeout(() => {
|
||||
this.socket?.destroy();
|
||||
reject(new Error(`Connection timeout after ${DAEMON.CONNECT_TIMEOUT}ms`));
|
||||
}, DAEMON.CONNECT_TIMEOUT);
|
||||
|
||||
this.socket = connect(this.socketPath);
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
clearTimeout(timeout);
|
||||
this.setupSocket();
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.socket.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Connection failed: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup socket event handlers
|
||||
*/
|
||||
private setupSocket(): void {
|
||||
if (!this.socket) return;
|
||||
|
||||
this.socket.on('data', (data) => {
|
||||
this.buffer += data.toString();
|
||||
|
||||
// Browser Pilot 패턴: 메시지 크기 제한 (DoS 방지)
|
||||
if (this.buffer.length > DAEMON.MAX_MESSAGE_SIZE) {
|
||||
logger.error(`Message size exceeded limit: ${this.buffer.length} bytes`);
|
||||
this.socket?.destroy();
|
||||
this.rejectAllPending(new Error('Message size exceeded limit'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Process complete JSON messages (delimited by newline)
|
||||
const messages = this.buffer.split('\n');
|
||||
this.buffer = messages.pop() || ''; // Keep incomplete message in buffer
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message.trim()) continue;
|
||||
|
||||
try {
|
||||
const response: IPCResponse = JSON.parse(message);
|
||||
this.handleResponse(response);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse response', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('error', (error) => {
|
||||
logger.error('Socket error', error);
|
||||
this.rejectAllPending(new Error(`Socket error: ${error.message}`));
|
||||
// Browser Pilot 패턴: 리소스 정리
|
||||
this.buffer = '';
|
||||
this.socket = null;
|
||||
});
|
||||
|
||||
this.socket.on('close', () => {
|
||||
// Browser Pilot 패턴: 리소스 정리
|
||||
this.buffer = '';
|
||||
this.socket = null;
|
||||
this.rejectAllPending(new Error('Connection closed'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle response from daemon
|
||||
*/
|
||||
private handleResponse(response: IPCResponse): void {
|
||||
const pending = this.pendingRequests.get(response.id);
|
||||
|
||||
if (!pending) {
|
||||
logger.warn(`Received response for unknown request: ${response.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(response.id);
|
||||
|
||||
if (response.success) {
|
||||
pending.resolve(response);
|
||||
} else {
|
||||
pending.reject(new Error(response.error || 'Command failed'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject all pending requests
|
||||
*/
|
||||
private rejectAllPending(error: Error): void {
|
||||
for (const [_id, pending] of this.pendingRequests.entries()) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request to daemon
|
||||
*/
|
||||
async sendRequest(command: string, params: Record<string, unknown> = {}, timeout: number = DAEMON.IPC_TIMEOUT): Promise<IPCResponse> {
|
||||
await this.connect();
|
||||
|
||||
if (!this.socket) {
|
||||
throw new Error('Not connected to daemon');
|
||||
}
|
||||
|
||||
const request: IPCRequest = {
|
||||
id: randomUUID(),
|
||||
command,
|
||||
params,
|
||||
timeout
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
this.pendingRequests.delete(request.id);
|
||||
reject(new Error(`Request timeout after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
this.pendingRequests.set(request.id, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout: timeoutHandle
|
||||
});
|
||||
|
||||
// Send request (newline-delimited JSON)
|
||||
this.socket!.write(JSON.stringify(request) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close connection
|
||||
*/
|
||||
close(): void {
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
this.rejectAllPending(new Error('Client closed'));
|
||||
}
|
||||
}
|
||||
275
skills/scripts/src/daemon/manager.ts
Normal file
275
skills/scripts/src/daemon/manager.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Daemon Process Manager
|
||||
* Handles starting, stopping, and checking status of the Blender Toolkit Daemon
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
||||
import { getOutputDir } from '../blender/config';
|
||||
import { IPCClient } from './client';
|
||||
import { PID_FILENAME, DaemonState, DAEMON_COMMANDS } from './protocol';
|
||||
import { DAEMON, TIMING } from '../constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export class DaemonManager {
|
||||
private outputDir: string;
|
||||
private pidPath: string;
|
||||
|
||||
constructor() {
|
||||
this.outputDir = getOutputDir();
|
||||
this.pidPath = join(this.outputDir, PID_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start daemon process
|
||||
*/
|
||||
async start(options: { verbose?: boolean } = {}): Promise<void> {
|
||||
const { verbose = true } = options;
|
||||
|
||||
// Check if already running
|
||||
if (await this.isRunning()) {
|
||||
if (verbose) {
|
||||
console.log(' Daemon is already running');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log('=<3D> Starting Blender Toolkit Daemon...');
|
||||
}
|
||||
|
||||
// Get path to server.js (compiled output)
|
||||
const serverPath = join(__dirname, 'server.js');
|
||||
|
||||
if (!existsSync(serverPath)) {
|
||||
throw new Error(`Daemon server not found at ${serverPath}. Did you run 'npm run build'?`);
|
||||
}
|
||||
|
||||
// Spawn daemon as detached process
|
||||
const daemon = spawn(process.execPath, [serverPath], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd: process.cwd(),
|
||||
env: process.env
|
||||
});
|
||||
|
||||
// Detach the process so it continues running when parent exits
|
||||
daemon.unref();
|
||||
|
||||
// Wait for daemon to start
|
||||
await this.waitForDaemon();
|
||||
|
||||
if (verbose) {
|
||||
console.log(' Daemon started successfully');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for daemon to be ready
|
||||
*/
|
||||
private async waitForDaemon(): Promise<void> {
|
||||
const maxAttempts = 10;
|
||||
const delay = 500; // 500ms
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
if (await this.isRunning()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Daemon failed to start');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop daemon process
|
||||
*/
|
||||
async stop(options: { verbose?: boolean; force?: boolean } = {}): Promise<void> {
|
||||
const { verbose = true, force = false } = options;
|
||||
|
||||
if (!(await this.isRunning())) {
|
||||
if (verbose) {
|
||||
console.log('Daemon is not running');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log('=<3D> Stopping Blender Toolkit Daemon...');
|
||||
}
|
||||
|
||||
if (force) {
|
||||
// Force kill via PID
|
||||
await this.forceKill();
|
||||
} else {
|
||||
// Graceful shutdown via IPC
|
||||
try {
|
||||
const client = new IPCClient();
|
||||
await client.sendRequest(DAEMON_COMMANDS.SHUTDOWN, {});
|
||||
client.close();
|
||||
|
||||
// Wait for shutdown
|
||||
await this.waitForShutdown();
|
||||
} catch (error) {
|
||||
if (verbose) {
|
||||
console.log('<27> Graceful shutdown failed, force killing...');
|
||||
}
|
||||
await this.forceKill();
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log(' Daemon stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force kill daemon process
|
||||
*/
|
||||
private async forceKill(): Promise<void> {
|
||||
if (!existsSync(this.pidPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pidStr = readFileSync(this.pidPath, 'utf-8').trim();
|
||||
const pid = parseInt(pidStr, 10);
|
||||
|
||||
if (isNaN(pid) || pid <= 0) {
|
||||
logger.warn(`Invalid PID in ${this.pidPath}: ${pidStr}`);
|
||||
unlinkSync(this.pidPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill process
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// If still running, force kill
|
||||
if (this.isProcessRunning(pid)) {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
}
|
||||
} catch (error) {
|
||||
// Process might already be dead
|
||||
}
|
||||
|
||||
// Remove PID file
|
||||
if (existsSync(this.pidPath)) {
|
||||
unlinkSync(this.pidPath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Force kill failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for daemon to shutdown
|
||||
*/
|
||||
private async waitForShutdown(): Promise<void> {
|
||||
const maxAttempts = 10;
|
||||
const delay = 500; // 500ms
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
if (!(await this.isRunning())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Daemon failed to shutdown gracefully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart daemon
|
||||
*/
|
||||
async restart(options: { verbose?: boolean } = {}): Promise<void> {
|
||||
const { verbose = true } = options;
|
||||
|
||||
if (verbose) {
|
||||
console.log('= Restarting Blender Toolkit Daemon...');
|
||||
}
|
||||
|
||||
await this.stop({ verbose: false });
|
||||
await this.start({ verbose: false });
|
||||
|
||||
if (verbose) {
|
||||
console.log(' Daemon restarted');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daemon status
|
||||
*/
|
||||
async getStatus(options: { verbose?: boolean } = {}): Promise<DaemonState | null> {
|
||||
const { verbose = true } = options;
|
||||
|
||||
if (!(await this.isRunning())) {
|
||||
if (verbose) {
|
||||
console.log('Daemon is not running');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new IPCClient();
|
||||
const response = await client.sendRequest(DAEMON_COMMANDS.GET_STATUS, {});
|
||||
client.close();
|
||||
|
||||
const state = response.data as DaemonState;
|
||||
|
||||
if (verbose) {
|
||||
console.log('Daemon Status:');
|
||||
console.log(` Connected to Blender: ${state.connected ? 'Yes' : 'No'}`);
|
||||
console.log(` Blender Port: ${state.port}`);
|
||||
console.log(` Uptime: ${Math.floor(state.uptime / 1000)}s`);
|
||||
console.log(` Last Activity: ${Math.floor((Date.now() - state.lastActivity) / 1000)}s ago`);
|
||||
}
|
||||
|
||||
return state;
|
||||
} catch (error) {
|
||||
if (verbose) {
|
||||
console.error('Failed to get status:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is running
|
||||
*/
|
||||
async isRunning(): Promise<boolean> {
|
||||
if (!existsSync(this.pidPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const pidStr = readFileSync(this.pidPath, 'utf-8').trim();
|
||||
const pid = parseInt(pidStr, 10);
|
||||
|
||||
if (isNaN(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.isProcessRunning(pid);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if process is running by PID
|
||||
*/
|
||||
private isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
// Signal 0 checks if process exists without killing it
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
78
skills/scripts/src/daemon/protocol.ts
Normal file
78
skills/scripts/src/daemon/protocol.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* IPC Protocol definitions for Blender Toolkit Daemon
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import { basename } from 'path';
|
||||
|
||||
/**
|
||||
* IPC Request from CLI to Daemon
|
||||
*/
|
||||
export interface IPCRequest {
|
||||
id: string;
|
||||
command: string;
|
||||
params: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC Response from Daemon to CLI
|
||||
*/
|
||||
export interface IPCResponse {
|
||||
id: string;
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daemon state information
|
||||
*/
|
||||
export interface DaemonState {
|
||||
connected: boolean;
|
||||
port: number | null;
|
||||
host: string;
|
||||
uptime: number;
|
||||
lastActivity: number;
|
||||
blenderVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File names and paths
|
||||
*/
|
||||
export const PID_FILENAME = 'daemon.pid';
|
||||
export const SOCKET_PATH_PREFIX = 'daemon';
|
||||
|
||||
/**
|
||||
* Get project-specific socket name for daemon IPC
|
||||
* Same logic as browser-pilot
|
||||
*/
|
||||
export function getProjectSocketName(projectRoot?: string): string {
|
||||
const root = projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||
const projectName = basename(root)
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
.toLowerCase();
|
||||
|
||||
// Add hash of full path to prevent collision
|
||||
const hash = createHash('sha256')
|
||||
.update(root)
|
||||
.digest('hex')
|
||||
.substring(0, 8);
|
||||
|
||||
return `${SOCKET_PATH_PREFIX}-${projectName}-${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daemon commands
|
||||
*/
|
||||
export const DAEMON_COMMANDS = {
|
||||
// Status commands
|
||||
PING: 'ping',
|
||||
GET_STATUS: 'get-status',
|
||||
SHUTDOWN: 'shutdown',
|
||||
|
||||
// Blender commands (pass-through to Blender WebSocket)
|
||||
BLENDER_COMMAND: 'blender-command',
|
||||
} as const;
|
||||
|
||||
export type DaemonCommand = typeof DAEMON_COMMANDS[keyof typeof DAEMON_COMMANDS];
|
||||
353
skills/scripts/src/daemon/server.ts
Normal file
353
skills/scripts/src/daemon/server.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Blender Toolkit Daemon Server
|
||||
* Detached background process that maintains connection to Blender WebSocket
|
||||
* and provides IPC interface for CLI commands
|
||||
*/
|
||||
|
||||
import { Server as NetServer, Socket as NetSocket, createServer } from 'net';
|
||||
import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { BlenderClient } from '../blender/client';
|
||||
import { getOutputDir, getProjectConfig } from '../blender/config';
|
||||
import {
|
||||
IPCRequest,
|
||||
IPCResponse,
|
||||
DaemonState,
|
||||
DAEMON_COMMANDS,
|
||||
PID_FILENAME,
|
||||
SOCKET_PATH_PREFIX,
|
||||
getProjectSocketName
|
||||
} from './protocol';
|
||||
import { DAEMON } from '../constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
class DaemonServer {
|
||||
private ipcServer: NetServer | null = null;
|
||||
private blenderClient: BlenderClient;
|
||||
private socketPath: string;
|
||||
private pidPath: string;
|
||||
private startTime: number;
|
||||
private lastActivity: number;
|
||||
private blenderPort: number = 9400;
|
||||
private shutdownRequested: boolean = false;
|
||||
// Browser Pilot 패턴: 활성 연결 추적
|
||||
private activeSockets: Set<NetSocket> = new Set();
|
||||
// Browser Pilot 패턴: shutdown Promise (race condition 방지)
|
||||
private shutdownPromise: Promise<void> | null = null;
|
||||
|
||||
constructor() {
|
||||
const outputDir = getOutputDir();
|
||||
this.socketPath = this.getSocketPath(outputDir);
|
||||
this.pidPath = join(outputDir, PID_FILENAME);
|
||||
this.blenderClient = new BlenderClient();
|
||||
this.startTime = Date.now();
|
||||
this.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get socket path (platform-specific)
|
||||
*/
|
||||
private getSocketPath(outputDir: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
const socketName = getProjectSocketName();
|
||||
return `\\\\.\\pipe\\${socketName}`;
|
||||
} else {
|
||||
return join(outputDir, `${SOCKET_PATH_PREFIX}.sock`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start daemon server
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
// Get project config for Blender port
|
||||
const config = await getProjectConfig();
|
||||
this.blenderPort = config.port;
|
||||
|
||||
logger.info(`Starting Blender Toolkit Daemon on port ${this.blenderPort}`);
|
||||
|
||||
// Write PID file
|
||||
writeFileSync(this.pidPath, String(process.pid), 'utf-8');
|
||||
logger.info(`PID file written: ${this.pidPath}`);
|
||||
|
||||
// Start IPC server
|
||||
await this.startIPCServer();
|
||||
|
||||
// Setup shutdown handlers
|
||||
this.setupShutdownHandlers();
|
||||
|
||||
logger.info(' Daemon started successfully');
|
||||
console.log(`Blender Toolkit Daemon started (PID: ${process.pid})`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start daemon:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start IPC server for CLI communication
|
||||
*/
|
||||
private async startIPCServer(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Remove existing socket file (Unix only)
|
||||
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
|
||||
unlinkSync(this.socketPath);
|
||||
}
|
||||
|
||||
this.ipcServer = createServer((socket: NetSocket) => {
|
||||
this.handleIPCConnection(socket);
|
||||
});
|
||||
|
||||
this.ipcServer.on('error', (error) => {
|
||||
logger.error('IPC server error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.ipcServer.listen(this.socketPath, () => {
|
||||
logger.info(`IPC server listening on ${this.socketPath}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IPC connection from CLI
|
||||
*/
|
||||
private handleIPCConnection(socket: NetSocket): void {
|
||||
logger.info('CLI client connected');
|
||||
|
||||
// Browser Pilot 패턴: 활성 소켓 추적
|
||||
this.activeSockets.add(socket);
|
||||
|
||||
let buffer = '';
|
||||
|
||||
socket.on('data', async (data) => {
|
||||
buffer += data.toString();
|
||||
|
||||
// Browser Pilot 패턴: 메시지 크기 제한 (DoS 방지)
|
||||
if (buffer.length > DAEMON.MAX_MESSAGE_SIZE) {
|
||||
logger.error(`Message size exceeded limit: ${buffer.length} bytes`);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Process newline-delimited JSON
|
||||
const messages = buffer.split('\n');
|
||||
buffer = messages.pop() || '';
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message.trim()) continue;
|
||||
|
||||
try {
|
||||
const request: IPCRequest = JSON.parse(message);
|
||||
const response = await this.handleIPCRequest(request);
|
||||
socket.write(JSON.stringify(response) + '\n');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Failed to handle IPC request:', errorMessage);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
logger.warn('IPC socket error:', error);
|
||||
// Browser Pilot 패턴: 활성 소켓에서 제거
|
||||
this.activeSockets.delete(socket);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
logger.info('CLI client disconnected');
|
||||
// Browser Pilot 패턴: 활성 소켓에서 제거
|
||||
this.activeSockets.delete(socket);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IPC request from CLI
|
||||
*/
|
||||
private async handleIPCRequest(request: IPCRequest): Promise<IPCResponse> {
|
||||
this.lastActivity = Date.now();
|
||||
|
||||
try {
|
||||
logger.info(`Handling command: ${request.command}`);
|
||||
|
||||
switch (request.command) {
|
||||
case DAEMON_COMMANDS.PING:
|
||||
return { id: request.id, success: true, data: { status: 'alive' } };
|
||||
|
||||
case DAEMON_COMMANDS.GET_STATUS:
|
||||
return { id: request.id, success: true, data: this.getStatus() };
|
||||
|
||||
case DAEMON_COMMANDS.SHUTDOWN:
|
||||
this.shutdown();
|
||||
return { id: request.id, success: true, data: { message: 'Shutting down' } };
|
||||
|
||||
case DAEMON_COMMANDS.BLENDER_COMMAND:
|
||||
// Forward command to Blender WebSocket
|
||||
const result = await this.forwardToBlender(request.params);
|
||||
return { id: request.id, success: true, data: result };
|
||||
|
||||
default:
|
||||
return {
|
||||
id: request.id,
|
||||
success: false,
|
||||
error: `Unknown command: ${request.command}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Command failed: ${errorMessage}`);
|
||||
return {
|
||||
id: request.id,
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward command to Blender WebSocket
|
||||
*/
|
||||
private async forwardToBlender(params: Record<string, unknown>): Promise<unknown> {
|
||||
try {
|
||||
// Connect to Blender if not connected
|
||||
if (!this.blenderClient.isConnected()) {
|
||||
await this.blenderClient.connect(this.blenderPort);
|
||||
logger.info(`Connected to Blender on port ${this.blenderPort}`);
|
||||
}
|
||||
|
||||
// Extract command method and params
|
||||
const method = params.method as string;
|
||||
const commandParams = params.params as Record<string, unknown>;
|
||||
|
||||
// Send command to Blender
|
||||
const result = await this.blenderClient.sendCommand(method, commandParams);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Blender command failed: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daemon status
|
||||
*/
|
||||
private getStatus(): DaemonState {
|
||||
const uptime = Date.now() - this.startTime;
|
||||
|
||||
return {
|
||||
connected: this.blenderClient.isConnected(),
|
||||
port: this.blenderPort,
|
||||
host: '127.0.0.1',
|
||||
uptime,
|
||||
lastActivity: this.lastActivity
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup shutdown handlers
|
||||
*/
|
||||
private setupShutdownHandlers(): void {
|
||||
const shutdown = (signal: string) => {
|
||||
logger.info(`Received ${signal}, shutting down...`);
|
||||
void this.shutdown();
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
process.on('SIGHUP', () => shutdown('SIGHUP'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown daemon
|
||||
* Browser Pilot 패턴: Race condition 방지
|
||||
*/
|
||||
private shutdown(): Promise<void> {
|
||||
// Race condition 방지: 이미 shutdown 중이면 기존 Promise 반환
|
||||
if (this.shutdownPromise) {
|
||||
return this.shutdownPromise;
|
||||
}
|
||||
|
||||
this.shutdownRequested = true;
|
||||
this.shutdownPromise = this.performShutdown();
|
||||
return this.shutdownPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 shutdown 수행 (내부 메서드)
|
||||
* Browser Pilot 패턴: Promise 기반 안전한 종료
|
||||
*/
|
||||
private async performShutdown(): Promise<void> {
|
||||
logger.info('Shutting down daemon...');
|
||||
|
||||
try {
|
||||
// 1. Close all active client connections
|
||||
logger.info(`Closing ${this.activeSockets.size} active connections...`);
|
||||
for (const socket of this.activeSockets) {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (error) {
|
||||
// Ignore individual socket errors
|
||||
}
|
||||
}
|
||||
this.activeSockets.clear();
|
||||
|
||||
// 2. Close Blender connection
|
||||
if (this.blenderClient.isConnected()) {
|
||||
this.blenderClient.disconnect();
|
||||
logger.info('Disconnected from Blender');
|
||||
}
|
||||
|
||||
// 3. Close IPC server with timeout
|
||||
if (this.ipcServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
logger.warn('IPC server close timeout, forcing...');
|
||||
resolve();
|
||||
}, DAEMON.SHUTDOWN_TIMEOUT);
|
||||
|
||||
this.ipcServer!.close(() => {
|
||||
clearTimeout(timeout);
|
||||
logger.info('IPC server closed');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Remove socket file (Unix only)
|
||||
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
|
||||
unlinkSync(this.socketPath);
|
||||
logger.info('Socket file removed');
|
||||
}
|
||||
|
||||
// 5. Remove PID file
|
||||
if (existsSync(this.pidPath)) {
|
||||
unlinkSync(this.pidPath);
|
||||
logger.info('PID file removed');
|
||||
}
|
||||
|
||||
logger.info('✓ Daemon shutdown complete');
|
||||
} catch (error) {
|
||||
logger.error('Error during shutdown:', error);
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Main entry point
|
||||
if (require.main === module) {
|
||||
const server = new DaemonServer();
|
||||
server.start().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export default DaemonServer;
|
||||
308
skills/scripts/src/index.ts
Normal file
308
skills/scripts/src/index.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Blender Animation Retargeting Workflow
|
||||
* Mixamo 애니메이션을 사용자 캐릭터에 리타게팅하는 전체 워크플로우
|
||||
*/
|
||||
|
||||
import { BlenderClient } from './blender/client';
|
||||
import { RetargetingController } from './blender/retargeting';
|
||||
import { MixamoHelper } from './blender/mixamo';
|
||||
import { BLENDER, FS, ERROR_MESSAGES, SUCCESS_MESSAGES } from './constants';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export interface RetargetWorkflowOptions {
|
||||
// Blender 설정
|
||||
blenderPort?: number;
|
||||
|
||||
// 캐릭터 설정
|
||||
targetCharacterArmature: string;
|
||||
|
||||
// 애니메이션 파일 설정
|
||||
animationFilePath: string; // FBX or DAE file path (manual download required)
|
||||
animationName?: string; // Optional animation name for NLA track
|
||||
|
||||
// 리타게팅 설정
|
||||
boneMapping?: 'auto' | 'mixamo_to_rigify' | 'custom';
|
||||
customBoneMap?: Record<string, string>;
|
||||
|
||||
// Confirmation workflow
|
||||
skipConfirmation?: boolean; // Skip bone mapping confirmation (use auto-mapping directly)
|
||||
|
||||
// 출력 설정
|
||||
outputDir?: string;
|
||||
}
|
||||
|
||||
export class AnimationRetargetingWorkflow {
|
||||
private blenderClient: BlenderClient;
|
||||
private retargetingController: RetargetingController;
|
||||
private mixamoHelper: MixamoHelper;
|
||||
private outputDir: string;
|
||||
|
||||
constructor() {
|
||||
this.blenderClient = new BlenderClient();
|
||||
this.retargetingController = new RetargetingController(this.blenderClient);
|
||||
this.mixamoHelper = new MixamoHelper();
|
||||
this.outputDir = join(process.cwd(), FS.OUTPUT_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 리타게팅 워크플로우 실행
|
||||
*
|
||||
* Workflow with user confirmation:
|
||||
* 1. Import animation FBX
|
||||
* 2. Auto-generate bone mapping
|
||||
* 3. Send mapping to Blender UI for review
|
||||
* 4. Wait for user confirmation (via AskUserQuestion)
|
||||
* 5. Retrieve edited mapping from Blender
|
||||
* 6. Apply retargeting with confirmed mapping
|
||||
*/
|
||||
async run(options: RetargetWorkflowOptions): Promise<void> {
|
||||
const {
|
||||
blenderPort = BLENDER.DEFAULT_PORT,
|
||||
targetCharacterArmature,
|
||||
animationFilePath,
|
||||
animationName,
|
||||
boneMapping = 'auto',
|
||||
customBoneMap,
|
||||
skipConfirmation = false,
|
||||
outputDir,
|
||||
} = options;
|
||||
|
||||
if (outputDir) {
|
||||
this.outputDir = outputDir;
|
||||
}
|
||||
|
||||
// 출력 디렉토리 생성
|
||||
this.ensureOutputDirectory();
|
||||
|
||||
// Validate animation file
|
||||
if (!existsSync(animationFilePath)) {
|
||||
throw new Error(`Animation file not found: ${animationFilePath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Blender에 연결
|
||||
console.log('🔌 Connecting to Blender...');
|
||||
await this.blenderClient.connect();
|
||||
console.log(SUCCESS_MESSAGES.CONNECTED);
|
||||
|
||||
// Step 2: 타겟 캐릭터 확인
|
||||
console.log('🔍 Checking target character...');
|
||||
const armatures = await this.getArmatures();
|
||||
if (!armatures.includes(targetCharacterArmature)) {
|
||||
throw new Error(
|
||||
`Target armature "${targetCharacterArmature}" not found. Available: ${armatures.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: 애니메이션 파일 임포트
|
||||
console.log(`📦 Importing animation from: ${animationFilePath}`);
|
||||
await this.importAnimation(animationFilePath);
|
||||
console.log(SUCCESS_MESSAGES.ANIMATION_IMPORTED);
|
||||
|
||||
// Step 4: Mixamo 아마추어 찾기 (방금 임포트된 것)
|
||||
const updatedArmatures = await this.getArmatures();
|
||||
const mixamoArmature = updatedArmatures.find(
|
||||
(name) => !armatures.includes(name)
|
||||
);
|
||||
|
||||
if (!mixamoArmature) {
|
||||
throw new Error('Failed to find imported animation armature');
|
||||
}
|
||||
|
||||
console.log(`✅ Found animation armature: ${mixamoArmature}`);
|
||||
|
||||
// Step 5: Auto-generate bone mapping
|
||||
console.log('🔍 Auto-generating bone mapping...');
|
||||
let finalBoneMap: Record<string, string>;
|
||||
|
||||
if (boneMapping === 'custom' && customBoneMap) {
|
||||
finalBoneMap = customBoneMap;
|
||||
} else {
|
||||
finalBoneMap = await this.retargetingController.autoMapBones(
|
||||
mixamoArmature,
|
||||
targetCharacterArmature
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`✅ Generated bone mapping (${Object.keys(finalBoneMap).length} bones)`);
|
||||
|
||||
// Step 6: Bone mapping confirmation workflow
|
||||
if (!skipConfirmation) {
|
||||
console.log('\n📋 Bone Mapping Preview:');
|
||||
console.log('─'.repeat(60));
|
||||
Object.entries(finalBoneMap).forEach(([source, target]) => {
|
||||
console.log(` ${source.padEnd(25)} → ${target}`);
|
||||
});
|
||||
console.log('─'.repeat(60));
|
||||
|
||||
// Send bone mapping to Blender UI
|
||||
console.log('\n📤 Sending bone mapping to Blender UI...');
|
||||
await this.blenderClient.sendCommand('BoneMapping.show', {
|
||||
sourceArmature: mixamoArmature,
|
||||
targetArmature: targetCharacterArmature,
|
||||
boneMapping: finalBoneMap,
|
||||
});
|
||||
|
||||
console.log('✅ Bone mapping displayed in Blender');
|
||||
console.log('\n⏸️ Please review the bone mapping in Blender:');
|
||||
console.log(' 1. Check the "Blender Toolkit" panel in the 3D View sidebar (N key)');
|
||||
console.log(' 2. Review the bone mapping table');
|
||||
console.log(' 3. Edit any incorrect mappings if needed');
|
||||
console.log(' 4. Click "Apply Retargeting" when ready');
|
||||
console.log('\nWaiting for user confirmation...\n');
|
||||
|
||||
// Note: In actual implementation with Claude Code, this would use AskUserQuestion
|
||||
// For now, we'll retrieve the mapping after a pause
|
||||
// TODO: Integrate with Claude Code's AskUserQuestion tool
|
||||
|
||||
// Retrieve edited bone mapping from Blender (with error recovery)
|
||||
console.log('📥 Retrieving bone mapping from Blender...');
|
||||
try {
|
||||
const retrievedMapping = await this.blenderClient.sendCommand<Record<string, string>>(
|
||||
'BoneMapping.get',
|
||||
{
|
||||
sourceArmature: mixamoArmature,
|
||||
targetArmature: targetCharacterArmature,
|
||||
}
|
||||
);
|
||||
|
||||
if (retrievedMapping && Object.keys(retrievedMapping).length > 0) {
|
||||
finalBoneMap = retrievedMapping;
|
||||
console.log(`✅ Using edited bone mapping (${Object.keys(finalBoneMap).length} bones)`);
|
||||
} else {
|
||||
console.log('⚠️ No edited mapping found, using auto-generated mapping');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to retrieve edited mapping, using auto-generated mapping');
|
||||
console.warn(` Error: ${error}`);
|
||||
// finalBoneMap already contains the auto-generated mapping, so no action needed
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: 리타게팅 실행
|
||||
console.log('\n🎬 Starting animation retargeting...');
|
||||
await this.retargetingController.retarget({
|
||||
sourceArmature: mixamoArmature,
|
||||
targetArmature: targetCharacterArmature,
|
||||
boneMapping: 'custom',
|
||||
customBoneMap: finalBoneMap,
|
||||
preserveRotation: true,
|
||||
preserveLocation: true,
|
||||
});
|
||||
|
||||
console.log(SUCCESS_MESSAGES.RETARGETING_COMPLETE);
|
||||
|
||||
// Step 8: NLA에 추가 (선택사항)
|
||||
const animations = await this.retargetingController.getAnimations(
|
||||
targetCharacterArmature
|
||||
);
|
||||
|
||||
if (animations.length > 0) {
|
||||
const latestAnimation = animations[animations.length - 1];
|
||||
const nlaTrackName = animationName || `Retargeted_${Date.now()}`;
|
||||
console.log(`📋 Adding animation to NLA track: ${nlaTrackName}`);
|
||||
await this.retargetingController.addToNLA(
|
||||
targetCharacterArmature,
|
||||
latestAnimation,
|
||||
nlaTrackName
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n✅ Animation retargeting completed successfully!\n');
|
||||
console.log('Next steps:');
|
||||
console.log(' 1. Review the retargeted animation in Blender');
|
||||
console.log(' 2. Adjust keyframes if needed');
|
||||
console.log(' 3. Export or save your scene');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Retargeting workflow failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
// 연결 종료
|
||||
await this.blenderClient.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 애니메이션 파일 임포트
|
||||
*/
|
||||
private async importAnimation(filepath: string): Promise<void> {
|
||||
const ext = filepath.split('.').pop()?.toLowerCase();
|
||||
|
||||
if (ext === 'fbx') {
|
||||
await this.blenderClient.sendCommand('Import.fbx', { filepath });
|
||||
} else if (ext === 'dae') {
|
||||
await this.blenderClient.sendCommand('Import.dae', { filepath });
|
||||
} else {
|
||||
throw new Error(`Unsupported file format: ${ext}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 아마추어 목록 가져오기
|
||||
*/
|
||||
private async getArmatures(): Promise<string[]> {
|
||||
return await this.blenderClient.sendCommand<string[]>('Armature.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* 출력 디렉토리 생성
|
||||
*/
|
||||
private ensureOutputDirectory(): void {
|
||||
if (!existsSync(this.outputDir)) {
|
||||
mkdirSync(this.outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const animationsDir = join(this.outputDir, FS.ANIMATIONS_DIR);
|
||||
if (!existsSync(animationsDir)) {
|
||||
mkdirSync(animationsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// .gitignore 생성
|
||||
const gitignorePath = join(this.outputDir, '.gitignore');
|
||||
if (!existsSync(gitignorePath)) {
|
||||
const fs = require('fs');
|
||||
fs.writeFileSync(gitignorePath, FS.GITIGNORE_CONTENT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get manual download instructions for Mixamo
|
||||
*/
|
||||
getManualDownloadInstructions(animationName: string): string {
|
||||
return this.mixamoHelper.getManualDownloadInstructions(animationName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of popular Mixamo animations
|
||||
*/
|
||||
getPopularAnimations() {
|
||||
return this.mixamoHelper.getPopularAnimations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended Mixamo download settings
|
||||
*/
|
||||
getRecommendedSettings() {
|
||||
return this.mixamoHelper.getRecommendedSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// CLI 사용 예시
|
||||
export async function runRetargetingFromCLI() {
|
||||
const workflow = new AnimationRetargetingWorkflow();
|
||||
|
||||
// Show manual download instructions
|
||||
console.log(workflow.getManualDownloadInstructions('Walking'));
|
||||
console.log('\nRecommended settings:', workflow.getRecommendedSettings());
|
||||
|
||||
// After manual download, run retargeting
|
||||
await workflow.run({
|
||||
targetCharacterArmature: 'MyCharacter', // User's character name
|
||||
animationFilePath: './animations/Walking.fbx', // Downloaded FBX path
|
||||
animationName: 'Walking', // Animation name for NLA track
|
||||
boneMapping: 'auto', // Auto bone mapping
|
||||
skipConfirmation: false, // Enable confirmation workflow
|
||||
});
|
||||
}
|
||||
92
skills/scripts/src/utils/logger.ts
Normal file
92
skills/scripts/src/utils/logger.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Winston Logger Configuration
|
||||
* TypeScript 애플리케이션용 로깅 시스템
|
||||
*/
|
||||
|
||||
import winston from 'winston';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
|
||||
// 로그 디렉토리 경로
|
||||
const LOG_DIR = join(process.cwd(), '.blender-toolkit', 'logs');
|
||||
|
||||
// 로그 디렉토리 생성
|
||||
if (!existsSync(LOG_DIR)) {
|
||||
mkdirSync(LOG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// 로그 포맷 정의
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.printf(({ timestamp, level, message, stack }) => {
|
||||
const logMessage = `[${timestamp}] [${level.toUpperCase().padEnd(5)}] ${message}`;
|
||||
return stack ? `${logMessage}\n${stack}` : logMessage;
|
||||
})
|
||||
);
|
||||
|
||||
// 콘솔용 컬러 포맷
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
||||
winston.format.printf(({ timestamp, level, message }) => {
|
||||
return `[${timestamp}] ${level}: ${message}`;
|
||||
})
|
||||
);
|
||||
|
||||
// Winston 로거 생성
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
transports: [
|
||||
// 파일 트랜스포트: 모든 로그
|
||||
new winston.transports.File({
|
||||
filename: join(LOG_DIR, 'typescript.log'),
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5,
|
||||
}),
|
||||
|
||||
// 파일 트랜스포트: 에러만
|
||||
new winston.transports.File({
|
||||
filename: join(LOG_DIR, 'error.log'),
|
||||
level: 'error',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// 개발 모드에서는 콘솔에도 출력
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(
|
||||
new winston.transports.Console({
|
||||
format: consoleFormat,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 디버그 모드 활성화
|
||||
if (process.env.DEBUG) {
|
||||
logger.level = 'debug';
|
||||
}
|
||||
|
||||
// 로거 래퍼 함수들 (사용 편의성)
|
||||
export const log = {
|
||||
debug: (message: string, ...meta: any[]) => logger.debug(message, ...meta),
|
||||
info: (message: string, ...meta: any[]) => logger.info(message, ...meta),
|
||||
warn: (message: string, ...meta: any[]) => logger.warn(message, ...meta),
|
||||
error: (message: string, ...meta: any[]) => logger.error(message, ...meta),
|
||||
};
|
||||
|
||||
// Named export (코드베이스 호환성)
|
||||
export { logger };
|
||||
|
||||
// 기본 export
|
||||
export default logger;
|
||||
|
||||
// 로거 초기화 메시지
|
||||
logger.info('Logger initialized', {
|
||||
logDir: LOG_DIR,
|
||||
level: logger.level,
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
});
|
||||
20
skills/scripts/tsconfig.json
Normal file
20
skills/scripts/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2023"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user