Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:18:51 +08:00
commit d80558b1cf
52 changed files with 12920 additions and 0 deletions

View 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
View 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)

View 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"
}
}

View 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;
}
}

View 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}`
);
}

View 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,
};
}
}

View 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>;
}
}

View 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();

View 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();
}
});
}

View 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);
}
});
}

View 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);
}
});
}

View 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();
}
});
}

View 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);
}
});
}

View 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);
}
});
}

View 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}`);
});
});
}

View 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;

View 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'));
}
}

View 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;
}
}
}

View 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];

View 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
View 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
});
}

View 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',
});

View 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"]
}