feat: Implemented Asset Placer and Animations system
This commit is contained in:
114
.clinerules
Normal file
114
.clinerules
Normal file
@@ -0,0 +1,114 @@
|
||||
# Cr8-xyz Project Rules
|
||||
version: 1.0
|
||||
systems:
|
||||
websocket:
|
||||
message_routing:
|
||||
- pattern: "command"
|
||||
handler: "command_handlers"
|
||||
tracking: true
|
||||
- pattern: "action"
|
||||
handler: "action_handlers"
|
||||
tracking: true
|
||||
- pattern: "status"
|
||||
handler: "status_handlers"
|
||||
tracking: false
|
||||
|
||||
session_management:
|
||||
max_retries: 5
|
||||
base_delay: 2000
|
||||
max_delay: 30000
|
||||
reconnect_strategy: "exponential_backoff"
|
||||
|
||||
asset_operations:
|
||||
operations:
|
||||
- name: "append_asset"
|
||||
params:
|
||||
[
|
||||
"empty_name",
|
||||
"filepath",
|
||||
"asset_name",
|
||||
"mode",
|
||||
"scale_factor",
|
||||
"center_origin",
|
||||
]
|
||||
tracking: true
|
||||
- name: "remove_assets"
|
||||
params: ["empty_name"]
|
||||
tracking: true
|
||||
- name: "swap_assets"
|
||||
params: ["empty1_name", "empty2_name"]
|
||||
tracking: true
|
||||
- name: "rotate_assets"
|
||||
params: ["empty_name", "degrees", "reset"]
|
||||
tracking: true
|
||||
- name: "scale_assets"
|
||||
params: ["empty_name", "scale_percent", "reset"]
|
||||
tracking: true
|
||||
- name: "get_asset_info"
|
||||
params: ["empty_name"]
|
||||
tracking: true
|
||||
|
||||
template_controls:
|
||||
scan_interval: 300
|
||||
max_controllables: 50
|
||||
tracking: true
|
||||
|
||||
error_handling:
|
||||
patterns:
|
||||
- type: "connection_error"
|
||||
retry: true
|
||||
max_attempts: 5
|
||||
- type: "message_processing_error"
|
||||
retry: false
|
||||
notify: true
|
||||
- type: "asset_operation_error"
|
||||
retry: true
|
||||
max_attempts: 3
|
||||
|
||||
knowledge_graph:
|
||||
auto_update:
|
||||
- trigger: "file_modified"
|
||||
paths: ["backend/**", "frontend/**"]
|
||||
action: "update_entity"
|
||||
- trigger: "new_endpoint"
|
||||
action: "create_api_documentation"
|
||||
- trigger: "asset_operation"
|
||||
action: "update_asset_relationships"
|
||||
- trigger: "websocket_message"
|
||||
action: "update_session_context"
|
||||
- trigger: "file_modified"
|
||||
paths: ["frontend/hooks/**"]
|
||||
action: "update_documentation"
|
||||
|
||||
context_capture:
|
||||
session_history:
|
||||
max_depth: 10
|
||||
auto_record:
|
||||
- command_patterns: ["pnpm run *", "python manage.py *"]
|
||||
- file_patterns: ["*.tsx", "*.py"]
|
||||
- websocket_messages: true
|
||||
|
||||
integrations:
|
||||
mcp_server: "github.com/modelcontextprotocol/servers/tree/main/src/memory"
|
||||
auto_sync: true
|
||||
|
||||
security:
|
||||
sensitive_files:
|
||||
- "**/blender_cr8tive_engine/blender_manifest.toml"
|
||||
- "**/app/core/config.py"
|
||||
- ".env"
|
||||
practices:
|
||||
- "Never expose MinIO credentials"
|
||||
- "Validate asset operation parameters"
|
||||
|
||||
components:
|
||||
relationships:
|
||||
- from: "frontend"
|
||||
to: "cr8_engine"
|
||||
type: "sends_commands_to"
|
||||
- from: "cr8_engine"
|
||||
to: "blender_cr8tive_engine"
|
||||
type: "controls"
|
||||
- from: "frontend"
|
||||
to: "blender_cr8tive_engine"
|
||||
type: "indirect_control"
|
@@ -1,8 +1,9 @@
|
||||
from . import (
|
||||
ws_handler,
|
||||
template_wizard,
|
||||
blender_controllers,
|
||||
preview_renderer
|
||||
ws,
|
||||
core,
|
||||
assets,
|
||||
rendering,
|
||||
templates
|
||||
)
|
||||
import bpy
|
||||
|
||||
@@ -21,12 +22,12 @@ bl_info = {
|
||||
|
||||
def register():
|
||||
"""Register all components of the addon"""
|
||||
ws_handler.register()
|
||||
ws.register()
|
||||
|
||||
|
||||
def unregister():
|
||||
"""Unregister all components of the addon"""
|
||||
ws_handler.unregister()
|
||||
ws.unregister()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
6
backend/blender_cr8tive_engine/assets/__init__.py
Normal file
6
backend/blender_cr8tive_engine/assets/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Asset management functionality for the Blender CR8tive Engine.
|
||||
This module provides utilities for working with assets in Blender.
|
||||
"""
|
||||
|
||||
from .asset_placer import AssetPlacer
|
7
backend/blender_cr8tive_engine/core/__init__.py
Normal file
7
backend/blender_cr8tive_engine/core/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Core functionality for the Blender CR8tive Engine.
|
||||
This module provides essential utilities for working with Blender.
|
||||
"""
|
||||
|
||||
from .animation_utils import *
|
||||
from .blender_controllers import BlenderControllers
|
119
backend/blender_cr8tive_engine/core/animation_utils.py
Normal file
119
backend/blender_cr8tive_engine/core/animation_utils.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import bpy
|
||||
from mathutils import Vector, Matrix
|
||||
|
||||
|
||||
def get_relative_transform(obj, target):
|
||||
"""Calculate relative transform between object and target"""
|
||||
target_inv = target.matrix_world.inverted()
|
||||
return target_inv @ obj.matrix_world
|
||||
|
||||
|
||||
def get_relative_location(location, target_matrix):
|
||||
"""Convert world location to target-relative location"""
|
||||
target_inv = target_matrix.inverted()
|
||||
relative_loc = target_inv @ Vector(location)
|
||||
return list(relative_loc)
|
||||
|
||||
|
||||
def relative_to_world_location(relative_loc, target_matrix):
|
||||
"""Convert target-relative location to world location"""
|
||||
return target_matrix @ Vector(relative_loc)
|
||||
|
||||
|
||||
def ensure_fcurve(action, data_path, array_index):
|
||||
"""Get existing F-curve or create new one if it doesn't exist"""
|
||||
for fc in action.fcurves:
|
||||
if fc.data_path == data_path and fc.array_index == array_index:
|
||||
fc.keyframe_points.clear()
|
||||
return fc
|
||||
return action.fcurves.new(data_path=data_path, index=array_index)
|
||||
|
||||
|
||||
def apply_animation_data(obj, action, animation_data, target_empty):
|
||||
"""Apply animation data to an object (light or camera)"""
|
||||
if not animation_data or not animation_data.get('keyframes'):
|
||||
return False
|
||||
|
||||
# Ensure object has animation data
|
||||
if not obj.animation_data:
|
||||
obj.animation_data_create()
|
||||
obj.animation_data.action = action
|
||||
|
||||
# Process keyframes
|
||||
for kf_data in animation_data['keyframes']:
|
||||
if kf_data['property'] == 'location':
|
||||
# Handle location keyframes
|
||||
world_loc = relative_to_world_location(
|
||||
kf_data['value'],
|
||||
target_empty.matrix_world
|
||||
)
|
||||
|
||||
# Create location fcurves if they don't exist
|
||||
for i in range(3):
|
||||
fcurve = ensure_fcurve(action, "location", i)
|
||||
point = fcurve.keyframe_points.insert(
|
||||
kf_data['frame'],
|
||||
world_loc[i]
|
||||
)
|
||||
point.interpolation = 'LINEAR'
|
||||
|
||||
elif kf_data.get('property') == 'light_property':
|
||||
# Handle light-specific properties
|
||||
fcurve = ensure_fcurve(
|
||||
action,
|
||||
kf_data['path'],
|
||||
kf_data['array_index']
|
||||
)
|
||||
point = fcurve.keyframe_points.insert(
|
||||
kf_data['frame'],
|
||||
kf_data['value']
|
||||
)
|
||||
if 'handle_left' in kf_data and 'handle_right' in kf_data:
|
||||
point.handle_left = kf_data['handle_left']
|
||||
point.handle_right = kf_data['handle_right']
|
||||
point.interpolation = kf_data.get('interpolation', 'BEZIER')
|
||||
|
||||
else:
|
||||
# Handle other properties (rotation, etc.)
|
||||
fcurve = ensure_fcurve(
|
||||
action,
|
||||
kf_data.get('path', f"{kf_data['property']}"),
|
||||
kf_data.get('array_index', 0)
|
||||
)
|
||||
point = fcurve.keyframe_points.insert(
|
||||
kf_data['frame'],
|
||||
kf_data['value']
|
||||
)
|
||||
if 'handle_left' in kf_data and 'handle_right' in kf_data:
|
||||
point.handle_left = kf_data['handle_left']
|
||||
point.handle_right = kf_data['handle_right']
|
||||
point.interpolation = kf_data.get('interpolation', 'BEZIER')
|
||||
|
||||
# Update fcurves
|
||||
for fc in action.fcurves:
|
||||
fc.update()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_animatable_light_properties(light):
|
||||
"""Get a list of animatable properties for a light"""
|
||||
properties = {
|
||||
'energy': 'data.energy',
|
||||
'color': 'data.color',
|
||||
'shadow_soft_size': 'data.shadow_soft_size',
|
||||
}
|
||||
|
||||
# Add type-specific properties
|
||||
if light.data.type == 'SPOT':
|
||||
properties.update({
|
||||
'spot_size': 'data.spot_size',
|
||||
'spot_blend': 'data.spot_blend',
|
||||
})
|
||||
elif light.data.type == 'AREA':
|
||||
properties.update({
|
||||
'size': 'data.size',
|
||||
'size_y': 'data.size_y',
|
||||
})
|
||||
|
||||
return properties
|
@@ -1,6 +1,6 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from .preview_renderer import get_preview_renderer
|
||||
from ..rendering.preview_renderer import get_preview_renderer
|
||||
|
||||
|
||||
class BlenderControllers:
|
7
backend/blender_cr8tive_engine/rendering/__init__.py
Normal file
7
backend/blender_cr8tive_engine/rendering/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Rendering functionality for the Blender CR8tive Engine.
|
||||
This module provides utilities for rendering and video generation in Blender.
|
||||
"""
|
||||
|
||||
from .preview_renderer import PreviewRenderer, get_preview_renderer
|
||||
from .video_generator import GenerateVideo
|
@@ -1,45 +0,0 @@
|
||||
import bpy
|
||||
|
||||
|
||||
class TemplateWizard:
|
||||
@staticmethod
|
||||
def scan_controllable_objects():
|
||||
"""
|
||||
Scan the current Blender scene for objects with 'controllable_' prefix
|
||||
Returns a dictionary of controllable objects categorized by type.
|
||||
Handles lights, cameras, materials, and objects.
|
||||
"""
|
||||
controllables = {
|
||||
'cameras': [
|
||||
{'name': camera.name, 'supported_controls': [
|
||||
'activate', 'settings']}
|
||||
for camera in bpy.data.cameras if camera.name.startswith('controllable_')
|
||||
],
|
||||
'lights': [
|
||||
{'name': light.name, 'type': light.type,
|
||||
'supported_controls': ['color', 'strength', 'temperature']}
|
||||
for light in bpy.data.lights if light.name.startswith('controllable_')
|
||||
],
|
||||
'materials': [
|
||||
{'name': material.name, 'supported_controls': [
|
||||
'color', 'roughness', 'metallic']}
|
||||
for material in bpy.data.materials if material.name.startswith('controllable_')
|
||||
],
|
||||
'objects': [
|
||||
{'name': obj.name, 'type': obj.type, 'supported_controls': [
|
||||
'location', 'rotation', 'scale']}
|
||||
for obj in bpy.data.objects if obj.name.startswith('controllable_')
|
||||
]
|
||||
}
|
||||
|
||||
return controllables
|
||||
|
||||
|
||||
def register():
|
||||
"""Register module (if needed)"""
|
||||
pass
|
||||
|
||||
|
||||
def unregister():
|
||||
"""Unregister module (if needed)"""
|
||||
pass
|
6
backend/blender_cr8tive_engine/templates/__init__.py
Normal file
6
backend/blender_cr8tive_engine/templates/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Template functionality for the Blender CR8tive Engine.
|
||||
This module provides utilities for working with templates in Blender.
|
||||
"""
|
||||
|
||||
from .template_wizard import TemplateWizard
|
102
backend/blender_cr8tive_engine/templates/template_wizard.py
Normal file
102
backend/blender_cr8tive_engine/templates/template_wizard.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import bpy
|
||||
|
||||
|
||||
class TemplateWizard:
|
||||
def get_template_controls(self):
|
||||
"""
|
||||
Get all template controls by scanning for controllable objects
|
||||
|
||||
Returns:
|
||||
List of control objects that can be manipulated
|
||||
"""
|
||||
# Use the existing scan method
|
||||
controllables = self.scan_controllable_objects()
|
||||
|
||||
# Convert to a list of controls
|
||||
controls = []
|
||||
|
||||
# Process cameras
|
||||
for camera in controllables.get('cameras', []):
|
||||
controls.append({
|
||||
'id': camera['name'],
|
||||
'type': 'camera',
|
||||
'name': camera['name'],
|
||||
'displayName': camera['name'].replace('controllable_', ''),
|
||||
'supported_controls': camera['supported_controls']
|
||||
})
|
||||
|
||||
# Process lights
|
||||
for light in controllables.get('lights', []):
|
||||
controls.append({
|
||||
'id': light['name'],
|
||||
'type': 'light',
|
||||
'name': light['name'],
|
||||
'displayName': light['name'].replace('controllable_', ''),
|
||||
'light_type': light['type'],
|
||||
'supported_controls': light['supported_controls']
|
||||
})
|
||||
|
||||
# Process materials
|
||||
for material in controllables.get('materials', []):
|
||||
controls.append({
|
||||
'id': material['name'],
|
||||
'type': 'material',
|
||||
'name': material['name'],
|
||||
'displayName': material['name'].replace('controllable_', ''),
|
||||
'supported_controls': material['supported_controls']
|
||||
})
|
||||
|
||||
# Process objects
|
||||
for obj in controllables.get('objects', []):
|
||||
controls.append({
|
||||
'id': obj['name'],
|
||||
'type': 'object',
|
||||
'name': obj['name'],
|
||||
'displayName': obj['name'].replace('controllable_', ''),
|
||||
'object_type': obj['type'],
|
||||
'supported_controls': obj['supported_controls']
|
||||
})
|
||||
|
||||
return controls
|
||||
|
||||
@staticmethod
|
||||
def scan_controllable_objects():
|
||||
"""
|
||||
Scan the current Blender scene for objects with 'controllable_' prefix
|
||||
Returns a dictionary of controllable objects categorized by type.
|
||||
Handles lights, cameras, materials, and objects.
|
||||
"""
|
||||
controllables = {
|
||||
'cameras': [
|
||||
{'name': camera.name, 'supported_controls': [
|
||||
'activate', 'settings']}
|
||||
for camera in bpy.data.cameras if camera.name.startswith('controllable_')
|
||||
],
|
||||
'lights': [
|
||||
{'name': light.name, 'type': light.type,
|
||||
'supported_controls': ['color', 'strength', 'temperature']}
|
||||
for light in bpy.data.lights if light.name.startswith('controllable_')
|
||||
],
|
||||
'materials': [
|
||||
{'name': material.name, 'supported_controls': [
|
||||
'color', 'roughness', 'metallic']}
|
||||
for material in bpy.data.materials if material.name.startswith('controllable_')
|
||||
],
|
||||
'objects': [
|
||||
{'name': obj.name, 'type': obj.type, 'supported_controls': [
|
||||
'location', 'rotation', 'scale']}
|
||||
for obj in bpy.data.objects if obj.name.startswith('controllable_')
|
||||
]
|
||||
}
|
||||
|
||||
return controllables
|
||||
|
||||
|
||||
def register():
|
||||
"""Register module (if needed)"""
|
||||
pass
|
||||
|
||||
|
||||
def unregister():
|
||||
"""Unregister module (if needed)"""
|
||||
pass
|
8
backend/blender_cr8tive_engine/ws/__init__.py
Normal file
8
backend/blender_cr8tive_engine/ws/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
WebSocket module for blender_cr8tive_engine.
|
||||
This module provides a modular approach to handling WebSocket communication.
|
||||
"""
|
||||
|
||||
from .websocket_handler import WebSocketHandler, get_handler, register, unregister
|
||||
|
||||
__all__ = ['WebSocketHandler', 'get_handler', 'register', 'unregister']
|
16
backend/blender_cr8tive_engine/ws/handlers/__init__.py
Normal file
16
backend/blender_cr8tive_engine/ws/handlers/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Command handlers for WebSocket communication.
|
||||
Each handler module contains related command handlers for a specific domain.
|
||||
"""
|
||||
|
||||
# Import handlers as they are implemented
|
||||
from .animation import AnimationHandlers
|
||||
from .asset import AssetHandlers
|
||||
from .render import RenderHandlers
|
||||
from .scene import SceneHandlers
|
||||
from .template import TemplateHandlers
|
||||
# etc.
|
||||
|
||||
# Will be populated as handlers are implemented
|
||||
__all__ = ['AnimationHandlers', 'AssetHandlers',
|
||||
'RenderHandlers', 'SceneHandlers', 'TemplateHandlers']
|
581
backend/blender_cr8tive_engine/ws/handlers/animation.py
Normal file
581
backend/blender_cr8tive_engine/ws/handlers/animation.py
Normal file
@@ -0,0 +1,581 @@
|
||||
"""
|
||||
Animation command handlers for WebSocket communication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import bpy
|
||||
from mathutils import Matrix, Vector
|
||||
from ...core.animation_utils import apply_animation_data, ensure_fcurve, relative_to_world_location
|
||||
from ..utils.response_manager import ResponseManager
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnimationHandlers:
|
||||
"""Handlers for animation-related WebSocket commands."""
|
||||
|
||||
# Get a single shared instance of ResponseManager
|
||||
response_manager = ResponseManager.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def handle_load_camera_animation(data):
|
||||
"""Handle loading camera animation to an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling load camera animation request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
template_data = data.get('template_data', {})
|
||||
empty_name = data.get('empty_name')
|
||||
|
||||
if not empty_name:
|
||||
raise ValueError("No target empty specified")
|
||||
|
||||
# Find the target empty in the scene
|
||||
target_empty = bpy.data.objects.get(empty_name)
|
||||
if not target_empty:
|
||||
raise ValueError(
|
||||
f"Target empty '{empty_name}' not found in scene")
|
||||
|
||||
# Apply the camera animation
|
||||
result = AnimationHandlers.apply_camera_animation(
|
||||
template_data, target_empty)
|
||||
|
||||
# Send response
|
||||
AnimationHandlers.response_manager.send_response('camera_animation_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading camera animation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Send error response
|
||||
AnimationHandlers.response_manager.send_response('camera_animation_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_load_light_animation(data):
|
||||
"""Handle loading light animation to an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling load light animation request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
template_data = data.get('template_data', {})
|
||||
empty_name = data.get('empty_name')
|
||||
|
||||
if not empty_name:
|
||||
raise ValueError("No target empty specified")
|
||||
|
||||
# Find the target empty in the scene
|
||||
target_empty = bpy.data.objects.get(empty_name)
|
||||
if not target_empty:
|
||||
raise ValueError(
|
||||
f"Target empty '{empty_name}' not found in scene")
|
||||
|
||||
# Apply the light animation
|
||||
result = AnimationHandlers.apply_light_animation(
|
||||
template_data, target_empty)
|
||||
|
||||
# Send response
|
||||
AnimationHandlers.response_manager.send_response('light_animation_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading light animation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Send error response
|
||||
AnimationHandlers.response_manager.send_response('light_animation_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_load_product_animation(data):
|
||||
"""Handle loading product animation to an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling load product animation request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
template_data = data.get('template_data', {})
|
||||
empty_name = data.get('empty_name')
|
||||
|
||||
if not empty_name:
|
||||
raise ValueError("No target empty specified")
|
||||
|
||||
# Find the target empty in the scene
|
||||
target_empty = bpy.data.objects.get(empty_name)
|
||||
if not target_empty:
|
||||
raise ValueError(
|
||||
f"Target empty '{empty_name}' not found in scene")
|
||||
|
||||
# Apply the product animation
|
||||
result = AnimationHandlers.apply_product_animation(
|
||||
template_data, target_empty)
|
||||
|
||||
# Send response
|
||||
AnimationHandlers.response_manager.send_response('product_animation_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading product animation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Send error response
|
||||
AnimationHandlers.response_manager.send_response('product_animation_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def create_or_get_fcurve(action, data_path, index):
|
||||
"""Get existing F-curve or create new one if it doesn't exist"""
|
||||
for fc in action.fcurves:
|
||||
if fc.data_path == data_path and fc.array_index == index:
|
||||
fc.keyframe_points.clear()
|
||||
return fc
|
||||
return action.fcurves.new(data_path=data_path, index=index)
|
||||
|
||||
@staticmethod
|
||||
def create_light(light_data, target_empty):
|
||||
"""Create a new light with the specified settings"""
|
||||
# Create new light data and object
|
||||
light_settings = light_data['light_settings']
|
||||
light_data_block = bpy.data.lights.new(
|
||||
name=light_settings['name'], type=light_settings['type'])
|
||||
light_obj = bpy.data.objects.new(
|
||||
name=light_settings['name'], object_data=light_data_block)
|
||||
|
||||
# Link to scene
|
||||
bpy.context.scene.collection.objects.link(light_obj)
|
||||
|
||||
# Apply basic settings
|
||||
light_data_block.energy = light_settings['energy']
|
||||
light_data_block.color = light_settings['color']
|
||||
light_data_block.shadow_soft_size = light_settings['shadow_soft_size']
|
||||
light_data_block.use_shadow = light_settings['use_shadow']
|
||||
|
||||
# Apply type-specific settings
|
||||
if light_settings['type'] == 'SPOT':
|
||||
light_data_block.spot_size = light_settings['spot_size']
|
||||
light_data_block.spot_blend = light_settings['spot_blend']
|
||||
elif light_settings['type'] == 'AREA':
|
||||
light_data_block.size = light_settings['size']
|
||||
light_data_block.size_y = light_settings['size_y']
|
||||
light_data_block.shape = light_settings['shape']
|
||||
|
||||
# Apply transform
|
||||
relative_matrix = Matrix([Vector(row)
|
||||
for row in light_data['relative_transform']])
|
||||
light_obj.matrix_world = target_empty.matrix_world @ relative_matrix
|
||||
|
||||
return light_obj
|
||||
|
||||
@staticmethod
|
||||
def apply_camera_animation(template_data, target_empty):
|
||||
"""Apply camera animation using the target empty"""
|
||||
try:
|
||||
template_name = template_data.get('name', 'camera')
|
||||
|
||||
# Create new camera
|
||||
cam_data = bpy.data.cameras.new(
|
||||
name=f"{template_name}_camera")
|
||||
cam_obj = bpy.data.objects.new(
|
||||
f"{template_name}_camera", cam_data)
|
||||
bpy.context.scene.collection.objects.link(cam_obj)
|
||||
|
||||
# Set as active camera
|
||||
bpy.context.scene.camera = cam_obj
|
||||
|
||||
# Apply relative transform if available
|
||||
if 'relative_transform' in template_data:
|
||||
relative_matrix = Matrix(
|
||||
[Vector(row) for row in template_data['relative_transform']])
|
||||
cam_obj.matrix_world = target_empty.matrix_world @ relative_matrix
|
||||
|
||||
# Apply camera settings
|
||||
if 'focal_length' in template_data:
|
||||
cam_data.lens = template_data['focal_length']
|
||||
if 'dof_distance' in template_data:
|
||||
cam_data.dof.focus_distance = template_data['dof_distance']
|
||||
|
||||
# Apply constraints
|
||||
for constraint_data in template_data.get('constraints', []):
|
||||
if constraint_data.get('type') == 'TRACK_TO':
|
||||
constraint = cam_obj.constraints.new('TRACK_TO')
|
||||
constraint.target = target_empty
|
||||
constraint.track_axis = constraint_data.get(
|
||||
'track_axis', 'TRACK_NEGATIVE_Z')
|
||||
constraint.up_axis = constraint_data.get('up_axis', 'UP_Y')
|
||||
|
||||
# Apply animation data
|
||||
if 'animation_data' in template_data:
|
||||
action_name = f"{template_name}_action"
|
||||
action = bpy.data.actions.new(name=action_name)
|
||||
|
||||
# Create location fcurves
|
||||
loc_fcurves = [
|
||||
AnimationHandlers.create_or_get_fcurve(
|
||||
action, "location", i)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
# Apply keyframes
|
||||
for kf_data in template_data['animation_data']['keyframes']:
|
||||
frame = kf_data['frame']
|
||||
|
||||
if kf_data['property'] == 'location':
|
||||
target_matrix = target_empty.matrix_world.copy()
|
||||
world_loc = relative_to_world_location(
|
||||
kf_data['value'], target_matrix)
|
||||
|
||||
for i, value in enumerate(world_loc):
|
||||
point = loc_fcurves[i].keyframe_points.insert(
|
||||
frame, value)
|
||||
point.interpolation = 'BEZIER'
|
||||
else:
|
||||
fcurve = AnimationHandlers.create_or_get_fcurve(
|
||||
action,
|
||||
kf_data['path'],
|
||||
kf_data['array_index']
|
||||
)
|
||||
point = fcurve.keyframe_points.insert(
|
||||
frame, kf_data['value'])
|
||||
point.handle_left = kf_data['handle_left']
|
||||
point.handle_right = kf_data['handle_right']
|
||||
point.interpolation = kf_data['interpolation']
|
||||
|
||||
if not cam_obj.animation_data:
|
||||
cam_obj.animation_data_create()
|
||||
cam_obj.animation_data.action = action
|
||||
|
||||
return {"success": True, "message": "Camera animation applied successfully"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying camera animation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def apply_light_animation(template_data, target_empty):
|
||||
"""Apply light animation using the target empty"""
|
||||
try:
|
||||
created_lights = []
|
||||
|
||||
# Process each light in the template
|
||||
for light_data in template_data.get('lights', []):
|
||||
# Create the light
|
||||
light_obj = AnimationHandlers.create_light(
|
||||
light_data, target_empty)
|
||||
created_lights.append(light_obj)
|
||||
|
||||
# Apply constraints
|
||||
for constraint_data in light_data.get('constraints', []):
|
||||
constraint_type = constraint_data.get('type')
|
||||
if constraint_type:
|
||||
constraint = light_obj.constraints.new(constraint_type)
|
||||
constraint.target = target_empty
|
||||
|
||||
if constraint_type == 'TRACK_TO':
|
||||
constraint.track_axis = constraint_data.get(
|
||||
'track_axis', 'TRACK_NEGATIVE_Z')
|
||||
constraint.up_axis = constraint_data.get(
|
||||
'up_axis', 'UP_Y')
|
||||
|
||||
# Apply animation data
|
||||
if light_data.get('animation_data'):
|
||||
action_name = f"{light_obj.name}_action"
|
||||
action = bpy.data.actions.new(name=action_name)
|
||||
|
||||
# Apply animation keyframes using the utility function
|
||||
apply_animation_data(
|
||||
light_obj,
|
||||
action,
|
||||
light_data['animation_data'],
|
||||
target_empty
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Light animation applied successfully with {len(created_lights)} lights",
|
||||
"lights_created": len(created_lights)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying light animation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def calculate_bounding_box(obj):
|
||||
"""Calculate object's bounding box in local space"""
|
||||
# Ensure mesh is up to date
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
obj_eval = obj.evaluated_get(depsgraph)
|
||||
|
||||
if not hasattr(obj_eval, 'bound_box'):
|
||||
return [0, 0, 0], [1, 1, 1]
|
||||
|
||||
# Calculate center and dimensions
|
||||
bbox_corners = [obj_eval.matrix_world @
|
||||
Vector(corner) for corner in obj_eval.bound_box]
|
||||
|
||||
min_x = min(corner.x for corner in bbox_corners)
|
||||
min_y = min(corner.y for corner in bbox_corners)
|
||||
min_z = min(corner.z for corner in bbox_corners)
|
||||
max_x = max(corner.x for corner in bbox_corners)
|
||||
max_y = max(corner.y for corner in bbox_corners)
|
||||
max_z = max(corner.z for corner in bbox_corners)
|
||||
|
||||
center = [(min_x + max_x)/2, (min_y + max_y)/2, (min_z + max_z)/2]
|
||||
dimensions = [max_x - min_x, max_y - min_y, max_z - min_z]
|
||||
|
||||
return center, dimensions
|
||||
|
||||
@staticmethod
|
||||
def adjust_animation_position(obj, original_location):
|
||||
"""Adjust the animation to maintain the object's original position"""
|
||||
if not obj.animation_data or not obj.animation_data.action:
|
||||
return
|
||||
|
||||
action = obj.animation_data.action
|
||||
|
||||
# Find location fcurves
|
||||
loc_fcurves = [
|
||||
fc for fc in action.fcurves if fc.data_path == 'location']
|
||||
|
||||
if not loc_fcurves or len(loc_fcurves) < 3:
|
||||
# If no location fcurves, create them
|
||||
for i in range(3):
|
||||
if i >= len(loc_fcurves):
|
||||
action.fcurves.new(data_path='location', index=i)
|
||||
|
||||
# Calculate the offset between current first keyframe and original location
|
||||
first_frame_loc = Vector([0, 0, 0])
|
||||
for i, fc in enumerate([fc for fc in action.fcurves if fc.data_path == 'location']):
|
||||
if fc.keyframe_points:
|
||||
# Get the value at the first keyframe
|
||||
first_frame_loc[i] = fc.keyframe_points[0].co[1]
|
||||
|
||||
# Calculate offset
|
||||
offset = Vector(original_location) - first_frame_loc
|
||||
|
||||
# Apply the offset to all location keyframes
|
||||
for i, fc in enumerate([fc for fc in action.fcurves if fc.data_path == 'location']):
|
||||
if i < 3: # Only X, Y, Z
|
||||
for kp in fc.keyframe_points:
|
||||
kp.co[1] += offset[i]
|
||||
# Update handles
|
||||
if hasattr(kp, 'handle_left') and hasattr(kp, 'handle_right'):
|
||||
kp.handle_left[1] += offset[i]
|
||||
kp.handle_right[1] += offset[i]
|
||||
|
||||
# Update fcurve
|
||||
fc.update()
|
||||
|
||||
@staticmethod
|
||||
def apply_product_animation(template_data, target_empty):
|
||||
"""Apply product animation to the target empty (parent of a product)"""
|
||||
try:
|
||||
# Extract animation data
|
||||
animation_data = template_data.get('animation_data', {})
|
||||
if not animation_data:
|
||||
raise ValueError("No animation data found in template")
|
||||
|
||||
# Log information about the target empty
|
||||
logger.info(
|
||||
f"Applying product animation to empty: {target_empty.name}")
|
||||
|
||||
# Check if the empty has children (products)
|
||||
children = [child for child in target_empty.children]
|
||||
product = None
|
||||
if children:
|
||||
logger.info(
|
||||
f"Found {len(children)} children objects for {target_empty.name}")
|
||||
# Find first child with vertices to use as product reference
|
||||
for child in children:
|
||||
if hasattr(child.data, 'vertices'):
|
||||
product = child
|
||||
break
|
||||
else:
|
||||
logger.warning(
|
||||
f"No children found for empty {target_empty.name}. Animation will only affect the empty.")
|
||||
|
||||
# Set rotation mode if needed
|
||||
base_rotation_mode = template_data.get('base_rotation_mode', 'XYZ')
|
||||
if base_rotation_mode != target_empty.rotation_mode:
|
||||
logger.info(
|
||||
f"Setting rotation mode from {target_empty.rotation_mode} to {base_rotation_mode}")
|
||||
target_empty.rotation_mode = base_rotation_mode
|
||||
|
||||
# Store original position for position preservation
|
||||
original_location = target_empty.location.copy()
|
||||
|
||||
# Create new action
|
||||
action_name = f"{target_empty.name}_{template_data.get('name', 'product')}_action"
|
||||
action = bpy.data.actions.new(name=action_name)
|
||||
|
||||
# Ensure target has animation data
|
||||
if not target_empty.animation_data:
|
||||
target_empty.animation_data_create()
|
||||
target_empty.animation_data.action = action
|
||||
|
||||
# Calculate scale adjustments if needed
|
||||
scale_factor = 1.0
|
||||
adjust_to_object_size = template_data.get(
|
||||
'adjust_to_object_size', True)
|
||||
|
||||
if adjust_to_object_size and product and 'product_bbox_dimensions' in animation_data:
|
||||
# Get current object dimensions
|
||||
current_bbox, current_dims = AnimationHandlers.calculate_bounding_box(
|
||||
product)
|
||||
template_dims = animation_data['product_bbox_dimensions']
|
||||
|
||||
# Calculate average scale difference
|
||||
template_size = sum(template_dims) / 3.0
|
||||
current_size = sum(current_dims) / 3.0
|
||||
|
||||
if template_size > 0:
|
||||
scale_factor = current_size / template_size
|
||||
logger.info(
|
||||
f"Adjusting animation scale by factor: {scale_factor}")
|
||||
|
||||
# Apply fcurves
|
||||
for fc_data in animation_data.get('fcurves', []):
|
||||
data_path = fc_data.get('data_path')
|
||||
array_index = fc_data.get('array_index', 0)
|
||||
|
||||
# Skip certain data paths if we don't want to transfer everything
|
||||
if data_path and data_path.startswith('delta_'):
|
||||
continue
|
||||
|
||||
# Create fcurve
|
||||
fcurve = ensure_fcurve(action, data_path, array_index)
|
||||
fcurve.extrapolation = fc_data.get('extrapolation', 'CONSTANT')
|
||||
|
||||
# Determine if we should apply scaling for this path
|
||||
apply_scale = False
|
||||
if adjust_to_object_size:
|
||||
if data_path == 'location':
|
||||
apply_scale = True
|
||||
|
||||
# Add keyframes
|
||||
for kf_data in fc_data.get('keyframes', []):
|
||||
# Create a copy to avoid modifying the original
|
||||
co = list(kf_data.get('co', [0, 0]))
|
||||
|
||||
# Apply scaling if needed
|
||||
if apply_scale and array_index < 3: # Only scale XYZ
|
||||
co[1] *= scale_factor
|
||||
|
||||
kf = fcurve.keyframe_points.insert(co[0], co[1])
|
||||
|
||||
# Set keyframe properties
|
||||
kf.interpolation = kf_data.get('interpolation', 'BEZIER')
|
||||
|
||||
# Set handle types
|
||||
if 'handle_left_type' in kf_data:
|
||||
kf.handle_left_type = kf_data['handle_left_type']
|
||||
if 'handle_right_type' in kf_data:
|
||||
kf.handle_right_type = kf_data['handle_right_type']
|
||||
|
||||
# Set handle positions
|
||||
if 'handle_left' in kf_data and 'handle_right' in kf_data:
|
||||
# Scale handles if needed
|
||||
hl = list(kf_data['handle_left'])
|
||||
hr = list(kf_data['handle_right'])
|
||||
|
||||
if apply_scale and array_index < 3:
|
||||
hl[1] *= scale_factor
|
||||
hr[1] *= scale_factor
|
||||
|
||||
kf.handle_left = hl
|
||||
kf.handle_right = hr
|
||||
|
||||
if 'easing' in kf_data:
|
||||
kf.easing = kf_data['easing']
|
||||
|
||||
# Apply drivers if present
|
||||
if 'drivers' in animation_data:
|
||||
for driver_data in animation_data.get('drivers', []):
|
||||
# Add driver
|
||||
data_path = driver_data['data_path']
|
||||
array_index = driver_data['array_index']
|
||||
|
||||
# Create driver
|
||||
try:
|
||||
driver = target_empty.animation_data.drivers.new(
|
||||
data_path, array_index)
|
||||
driver.driver.type = driver_data.get(
|
||||
'driver_type', 'AVERAGE')
|
||||
driver.driver.expression = driver_data.get(
|
||||
'expression', '')
|
||||
|
||||
# Add variables
|
||||
for var_data in driver_data.get('variables', []):
|
||||
var = driver.driver.variables.new()
|
||||
var.name = var_data['name']
|
||||
var.type = var_data['type']
|
||||
|
||||
# Add targets
|
||||
for i, target_data in enumerate(var_data.get('targets', [])):
|
||||
if i < len(var.targets):
|
||||
target = var.targets[i]
|
||||
target.data_path = target_data.get(
|
||||
'data_path', '')
|
||||
target.transform_type = target_data.get(
|
||||
'transform_type', 'LOC_X')
|
||||
target.transform_space = target_data.get(
|
||||
'transform_space', 'WORLD_SPACE')
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not add driver for {data_path}: {str(e)}")
|
||||
|
||||
# Update fcurves
|
||||
for fc in action.fcurves:
|
||||
fc.update()
|
||||
|
||||
# Adjust animation to preserve original position if requested
|
||||
preserve_target_position = template_data.get(
|
||||
'preserve_target_position', True)
|
||||
if preserve_target_position:
|
||||
AnimationHandlers.adjust_animation_position(
|
||||
target_empty, original_location)
|
||||
|
||||
# Set scene end frame if needed
|
||||
duration = template_data.get('duration')
|
||||
if duration:
|
||||
bpy.context.scene.frame_end = max(
|
||||
bpy.context.scene.frame_end,
|
||||
bpy.context.scene.frame_start + duration
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Product animation applied successfully to empty and its children",
|
||||
"empty_name": target_empty.name,
|
||||
"children_count": len(children)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying product animation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"success": False, "message": str(e)}
|
227
backend/blender_cr8tive_engine/ws/handlers/asset.py
Normal file
227
backend/blender_cr8tive_engine/ws/handlers/asset.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Asset command handlers for WebSocket communication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import bpy
|
||||
from ...assets.asset_placer import AssetPlacer
|
||||
from ..utils.response_manager import ResponseManager
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AssetHandlers:
|
||||
"""Handlers for asset-related WebSocket commands."""
|
||||
|
||||
# Create a single shared instance of AssetPlacer
|
||||
asset_placer = AssetPlacer()
|
||||
|
||||
# Get a single shared instance of ResponseManager
|
||||
response_manager = ResponseManager.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def handle_append_asset(data):
|
||||
"""Handle appending an asset to an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling append asset request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
empty_name = data.get('empty_name')
|
||||
filepath = data.get('filepath')
|
||||
asset_name = data.get('asset_name')
|
||||
mode = data.get('mode', 'PLACE')
|
||||
scale_factor = data.get('scale_factor', 1.0)
|
||||
center_origin = data.get('center_origin', False)
|
||||
|
||||
# Call the asset placer to append the asset
|
||||
result = AssetHandlers.asset_placer.append_asset(
|
||||
empty_name,
|
||||
filepath,
|
||||
asset_name,
|
||||
mode=mode,
|
||||
scale_factor=scale_factor,
|
||||
center_origin=center_origin
|
||||
)
|
||||
|
||||
logger.info(f"Asset append result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
AssetHandlers.response_manager.send_response('append_asset_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during asset append: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
AssetHandlers.response_manager.send_response('append_asset_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_remove_assets(data):
|
||||
"""Handle removing assets from an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling remove assets request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
empty_name = data.get('empty_name')
|
||||
|
||||
# Call the asset placer to remove the assets
|
||||
result = AssetHandlers.asset_placer.remove_assets(empty_name)
|
||||
|
||||
logger.info(f"Asset removal result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
AssetHandlers.response_manager.send_response('remove_assets_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during asset removal: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
AssetHandlers.response_manager.send_response('remove_assets_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_swap_assets(data):
|
||||
"""Handle swapping assets between two empties"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling swap assets request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
empty1_name = data.get('empty1_name')
|
||||
empty2_name = data.get('empty2_name')
|
||||
|
||||
# Call the asset placer to swap the assets
|
||||
result = AssetHandlers.asset_placer.swap_assets(
|
||||
empty1_name, empty2_name)
|
||||
|
||||
logger.info(f"Asset swap result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
AssetHandlers.response_manager.send_response('swap_assets_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during asset swap: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
AssetHandlers.response_manager.send_response('swap_assets_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_rotate_assets(data):
|
||||
"""Handle rotating assets on an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling rotate assets request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
empty_name = data.get('empty_name')
|
||||
degrees = data.get('degrees')
|
||||
reset = data.get('reset', False)
|
||||
|
||||
# Call the asset placer to rotate the assets
|
||||
result = AssetHandlers.asset_placer.rotate_assets(
|
||||
empty_name, degrees, reset)
|
||||
|
||||
logger.info(f"Asset rotation result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
AssetHandlers.response_manager.send_response('rotate_assets_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during asset rotation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
AssetHandlers.response_manager.send_response('rotate_assets_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_scale_assets(data):
|
||||
"""Handle scaling assets on an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling scale assets request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
empty_name = data.get('empty_name')
|
||||
scale_percent = data.get('scale_percent')
|
||||
reset = data.get('reset', False)
|
||||
|
||||
# Call the asset placer to scale the assets
|
||||
result = AssetHandlers.asset_placer.scale_assets(
|
||||
empty_name, scale_percent, reset)
|
||||
|
||||
logger.info(f"Asset scaling result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
AssetHandlers.response_manager.send_response('scale_assets_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during asset scaling: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
AssetHandlers.response_manager.send_response('scale_assets_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_get_asset_info(data):
|
||||
"""Handle getting information about assets on an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling get asset info request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
empty_name = data.get('empty_name')
|
||||
|
||||
# Call the asset placer to get the asset info
|
||||
result = AssetHandlers.asset_placer.get_asset_info(empty_name)
|
||||
|
||||
logger.info(f"Asset info result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
AssetHandlers.response_manager.send_response('asset_info_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during get asset info: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
AssetHandlers.response_manager.send_response('asset_info_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
56
backend/blender_cr8tive_engine/ws/handlers/camera.py
Normal file
56
backend/blender_cr8tive_engine/ws/handlers/camera.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Camera command handlers for WebSocket communication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from ...core.blender_controllers import BlenderControllers
|
||||
from ..utils.response_manager import ResponseManager
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CameraHandlers:
|
||||
"""Handlers for camera-related WebSocket commands."""
|
||||
|
||||
# Create a single shared instance of BlenderControllers
|
||||
controllers = BlenderControllers()
|
||||
|
||||
# Get a single shared instance of ResponseManager
|
||||
response_manager = ResponseManager.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def handle_update_camera(data):
|
||||
"""Handle camera update request"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling update_camera request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
camera_name = data.get('camera_name')
|
||||
|
||||
if not camera_name:
|
||||
raise ValueError("Missing camera_name parameter")
|
||||
|
||||
# Update the camera
|
||||
result = CameraHandlers.controllers.set_active_camera(camera_name)
|
||||
logger.info(f"Camera update result: {result}")
|
||||
|
||||
# Send success response
|
||||
CameraHandlers.response_manager.send_response('update_camera_result', True, {
|
||||
"success": True,
|
||||
"camera_name": camera_name,
|
||||
"message": "Camera updated successfully",
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Camera update error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
CameraHandlers.response_manager.send_response('update_camera_result', False, {
|
||||
"success": False,
|
||||
"message": f"Camera update failed: {str(e)}",
|
||||
"message_id": data.get('message_id')
|
||||
})
|
62
backend/blender_cr8tive_engine/ws/handlers/light.py
Normal file
62
backend/blender_cr8tive_engine/ws/handlers/light.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Light command handlers for WebSocket communication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from ...core.blender_controllers import BlenderControllers
|
||||
from ..utils.response_manager import ResponseManager
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LightHandlers:
|
||||
"""Handlers for light-related WebSocket commands."""
|
||||
|
||||
# Create a single shared instance of BlenderControllers
|
||||
controllers = BlenderControllers()
|
||||
|
||||
# Get a single shared instance of ResponseManager
|
||||
response_manager = ResponseManager.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def handle_update_light(data):
|
||||
"""Handle light update request"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling update_light request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
light_name = data.get('light_name')
|
||||
color = data.get('color')
|
||||
strength = data.get('strength')
|
||||
|
||||
if not light_name:
|
||||
raise ValueError("Missing light_name parameter")
|
||||
|
||||
# Update the light
|
||||
result = LightHandlers.controllers.update_light(
|
||||
light_name,
|
||||
color=color,
|
||||
strength=strength
|
||||
)
|
||||
logger.info(f"Light update result: {result}")
|
||||
|
||||
# Send success response
|
||||
LightHandlers.response_manager.send_response('update_light_result', True, {
|
||||
"success": True,
|
||||
"light_name": light_name,
|
||||
"message": "Light updated successfully",
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Light update error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
LightHandlers.response_manager.send_response('update_light_result', False, {
|
||||
"success": False,
|
||||
"message": f"Light update failed: {str(e)}",
|
||||
"message_id": data.get('message_id')
|
||||
})
|
64
backend/blender_cr8tive_engine/ws/handlers/material.py
Normal file
64
backend/blender_cr8tive_engine/ws/handlers/material.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Material command handlers for WebSocket communication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from ...core.blender_controllers import BlenderControllers
|
||||
from ..utils.response_manager import ResponseManager
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaterialHandlers:
|
||||
"""Handlers for material-related WebSocket commands."""
|
||||
|
||||
# Create a single shared instance of BlenderControllers
|
||||
controllers = BlenderControllers()
|
||||
|
||||
# Get a single shared instance of ResponseManager
|
||||
response_manager = ResponseManager.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def handle_update_material(data):
|
||||
"""Handle material update request"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling update_material request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
material_name = data.get('material_name')
|
||||
color = data.get('color')
|
||||
roughness = data.get('roughness')
|
||||
metallic = data.get('metallic')
|
||||
|
||||
if not material_name:
|
||||
raise ValueError("Missing material_name parameter")
|
||||
|
||||
# Update the material
|
||||
result = MaterialHandlers.controllers.update_material(
|
||||
material_name,
|
||||
color=color,
|
||||
roughness=roughness,
|
||||
metallic=metallic
|
||||
)
|
||||
logger.info(f"Material update result: {result}")
|
||||
|
||||
# Send success response
|
||||
MaterialHandlers.response_manager.send_response('update_material_result', True, {
|
||||
"success": True,
|
||||
"material_name": material_name,
|
||||
"message": "Material updated successfully",
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Material update error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
MaterialHandlers.response_manager.send_response('update_material_result', False, {
|
||||
"success": False,
|
||||
"message": f"Material update failed: {str(e)}",
|
||||
"message_id": data.get('message_id')
|
||||
})
|
64
backend/blender_cr8tive_engine/ws/handlers/object.py
Normal file
64
backend/blender_cr8tive_engine/ws/handlers/object.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Object command handlers for WebSocket communication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from ...core.blender_controllers import BlenderControllers
|
||||
from ..utils.response_manager import ResponseManager
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ObjectHandlers:
|
||||
"""Handlers for object-related WebSocket commands."""
|
||||
|
||||
# Create a single shared instance of BlenderControllers
|
||||
controllers = BlenderControllers()
|
||||
|
||||
# Get a single shared instance of ResponseManager
|
||||
response_manager = ResponseManager.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def handle_update_object(data):
|
||||
"""Handle object update request"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling update_object request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
object_name = data.get('object_name')
|
||||
location = data.get('location')
|
||||
rotation = data.get('rotation')
|
||||
scale = data.get('scale')
|
||||
|
||||
if not object_name:
|
||||
raise ValueError("Missing object_name parameter")
|
||||
|
||||
# Update the object
|
||||
result = ObjectHandlers.controllers.update_object(
|
||||
object_name,
|
||||
location=location,
|
||||
rotation=rotation,
|
||||
scale=scale
|
||||
)
|
||||
logger.info(f"Object update result: {result}")
|
||||
|
||||
# Send success response
|
||||
ObjectHandlers.response_manager.send_response('update_object_result', True, {
|
||||
"success": True,
|
||||
"object_name": object_name,
|
||||
"message": "Object updated successfully",
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Object update error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
ObjectHandlers.response_manager.send_response('update_object_result', False, {
|
||||
"success": False,
|
||||
"message": f"Object update failed: {str(e)}",
|
||||
"message_id": data.get('message_id')
|
||||
})
|
130
backend/blender_cr8tive_engine/ws/handlers/render.py
Normal file
130
backend/blender_cr8tive_engine/ws/handlers/render.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Rendering command handlers for WebSocket communication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import bpy
|
||||
import os
|
||||
from pathlib import Path
|
||||
from ...rendering.video_generator import GenerateVideo
|
||||
from ...rendering.preview_renderer import get_preview_renderer
|
||||
from ...core.blender_controllers import BlenderControllers
|
||||
from ..utils.session_manager import SessionManager
|
||||
from ..utils.response_manager import ResponseManager
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RenderHandlers:
|
||||
"""Handlers for rendering-related WebSocket commands."""
|
||||
|
||||
# Create a single shared instance of BlenderControllers
|
||||
controllers = BlenderControllers()
|
||||
|
||||
# Get a single shared instance of ResponseManager
|
||||
response_manager = ResponseManager.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def handle_preview_rendering(data):
|
||||
"""Handle preview rendering request"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling preview rendering request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
params = data.get('params', {})
|
||||
|
||||
# Get username from SessionManager
|
||||
session_manager = SessionManager.get_instance()
|
||||
username = session_manager.get_username()
|
||||
|
||||
# Get the preview renderer
|
||||
preview_renderer = get_preview_renderer(username)
|
||||
|
||||
# Cleanup any existing preview frames
|
||||
preview_renderer.cleanup()
|
||||
|
||||
# Setup preview render settings
|
||||
preview_renderer.setup_preview_render(params)
|
||||
|
||||
# Render the entire animation once
|
||||
bpy.ops.render.opengl(animation=True)
|
||||
|
||||
# Send success response
|
||||
RenderHandlers.response_manager.send_response('start_broadcast', True, {
|
||||
"message": "Preview rendering started",
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Preview rendering error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
RenderHandlers.response_manager.send_response('start_broadcast', False, {
|
||||
"message": f"Preview rendering failed: {str(e)}",
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_generate_video(data):
|
||||
"""Handle video generation request"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling generate video request with message_id: {message_id}")
|
||||
|
||||
# Get username from SessionManager
|
||||
session_manager = SessionManager.get_instance()
|
||||
username = session_manager.get_username()
|
||||
|
||||
# Set up paths
|
||||
image_sequence_directory = Path(
|
||||
f"/mnt/shared_storage/Cr8tive_Engine/Sessions/{username}") / "preview"
|
||||
output_file = image_sequence_directory / "preview.mp4"
|
||||
resolution = (1280, 720)
|
||||
fps = 30
|
||||
|
||||
# Ensure the directory exists
|
||||
image_sequence_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check if there are actually image files in the directory
|
||||
image_files = list(image_sequence_directory.glob('*.png'))
|
||||
if not image_files:
|
||||
raise ValueError(
|
||||
"No image files found in the specified directory")
|
||||
|
||||
# Initialize and execute the handler
|
||||
video_generator = GenerateVideo(
|
||||
str(image_sequence_directory),
|
||||
str(output_file),
|
||||
resolution,
|
||||
fps
|
||||
)
|
||||
video_generator.gen_video_from_images()
|
||||
|
||||
# Send success response
|
||||
RenderHandlers.response_manager.send_response('generate_video', True, {
|
||||
"success": True,
|
||||
"status": "completed",
|
||||
"message": "Video generation completed successfully",
|
||||
"output_file": str(output_file),
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.error(
|
||||
f"Video generation error: {error_message}"
|
||||
)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Send error response
|
||||
RenderHandlers.response_manager.send_response('generate_video', False, {
|
||||
"success": False,
|
||||
"status": "failed",
|
||||
"message": f"Video generation failed: {error_message}",
|
||||
"message_id": data.get('message_id')
|
||||
})
|
157
backend/blender_cr8tive_engine/ws/handlers/scene.py
Normal file
157
backend/blender_cr8tive_engine/ws/handlers/scene.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Scene command handlers for WebSocket communication.
|
||||
This includes camera, light, material, and object transformation handlers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import bpy
|
||||
from ...core.blender_controllers import BlenderControllers
|
||||
from ..utils.response_manager import ResponseManager
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SceneHandlers:
|
||||
"""Handlers for scene-related WebSocket commands."""
|
||||
|
||||
# Create a single shared instance of BlenderControllers
|
||||
controllers = BlenderControllers()
|
||||
|
||||
# Get a single shared instance of ResponseManager
|
||||
response_manager = ResponseManager.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def handle_camera_change(data):
|
||||
"""Handle changing the active camera"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling camera change request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
camera_name = data.get('camera_name')
|
||||
|
||||
# Call the controllers to change the camera
|
||||
result = SceneHandlers.controllers.set_active_camera(camera_name)
|
||||
|
||||
logger.info(f"Camera change result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
SceneHandlers.response_manager.send_response('camera_change_result', result, {
|
||||
"message": f"Camera changed to {camera_name}" if result else f"Failed to change camera to {camera_name}",
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during camera change: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
SceneHandlers.response_manager.send_response('camera_change_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_light_update(data):
|
||||
"""Handle updating a light's properties"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling light update request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
light_name = data.get('light_name')
|
||||
color = data.get('color')
|
||||
strength = data.get('strength')
|
||||
|
||||
# Call the controllers to update the light
|
||||
result = SceneHandlers.controllers.update_light(
|
||||
light_name, color=color, strength=strength)
|
||||
|
||||
logger.info(f"Light update result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
SceneHandlers.response_manager.send_response('light_update_result', result, {
|
||||
"message": f"Light {light_name} updated" if result else f"Failed to update light {light_name}",
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during light update: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
SceneHandlers.response_manager.send_response('light_update_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_material_update(data):
|
||||
"""Handle updating a material's properties"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling material update request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
material_name = data.get('material_name')
|
||||
color = data.get('color')
|
||||
roughness = data.get('roughness')
|
||||
metallic = data.get('metallic')
|
||||
|
||||
# Call the controllers to update the material
|
||||
result = SceneHandlers.controllers.update_material(
|
||||
material_name, color=color, roughness=roughness, metallic=metallic)
|
||||
|
||||
logger.info(f"Material update result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
SceneHandlers.response_manager.send_response('material_update_result', result, {
|
||||
"message": f"Material {material_name} updated" if result else f"Failed to update material {material_name}",
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during material update: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
SceneHandlers.response_manager.send_response('material_update_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_object_transformation(data):
|
||||
"""Handle transforming an object (location, rotation, scale)"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling object transformation request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
object_name = data.get('object_name')
|
||||
location = data.get('location')
|
||||
rotation = data.get('rotation')
|
||||
scale = data.get('scale')
|
||||
|
||||
# Call the controllers to update the object
|
||||
result = SceneHandlers.controllers.update_object(
|
||||
object_name, location=location, rotation=rotation, scale=scale)
|
||||
|
||||
logger.info(f"Object transformation result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
SceneHandlers.response_manager.send_response('object_transformation_result', result, {
|
||||
"message": f"Object {object_name} transformed" if result else f"Failed to transform object {object_name}",
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during object transformation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
SceneHandlers.response_manager.send_response('object_transformation_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
122
backend/blender_cr8tive_engine/ws/handlers/template.py
Normal file
122
backend/blender_cr8tive_engine/ws/handlers/template.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Template command handlers for WebSocket communication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import bpy
|
||||
import json
|
||||
from ...templates.template_wizard import TemplateWizard
|
||||
from ..utils.response_manager import ResponseManager
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateHandlers:
|
||||
"""Handlers for template-related WebSocket commands."""
|
||||
|
||||
# Create a single shared instance of TemplateWizard
|
||||
template_wizard = TemplateWizard()
|
||||
|
||||
# Get a single shared instance of ResponseManager
|
||||
response_manager = ResponseManager.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def handle_get_template_controls(data):
|
||||
"""Handle getting template controls"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling get template controls request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
template_name = data.get('template_name')
|
||||
|
||||
# Get the template controls
|
||||
controls = TemplateHandlers.template_wizard.get_template_controls()
|
||||
|
||||
logger.info(
|
||||
f"Template controls retrieved: {len(controls)} controls")
|
||||
|
||||
# Send response with the controls
|
||||
TemplateHandlers.response_manager.send_response('template_controls_result', True, {
|
||||
"controls": controls,
|
||||
"template_name": template_name,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting template controls: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
TemplateHandlers.response_manager.send_response('template_controls_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_update_template_control(data):
|
||||
"""Handle updating a template control value"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling update template control request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
template_name = data.get('template_name')
|
||||
control_id = data.get('control_id')
|
||||
value = data.get('value')
|
||||
|
||||
# Update the control value
|
||||
result = TemplateHandlers.template_wizard.update_control_value(
|
||||
template_name, control_id, value)
|
||||
|
||||
logger.info(f"Template control update result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
TemplateHandlers.response_manager.send_response('update_template_control_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating template control: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
TemplateHandlers.response_manager.send_response('update_template_control_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def handle_get_template_info(data):
|
||||
"""Handle getting template information"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logger.info(
|
||||
f"Handling get template info request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
template_name = data.get('template_name')
|
||||
|
||||
# Get the template info
|
||||
info = TemplateHandlers.template_wizard.get_template_info(
|
||||
template_name)
|
||||
|
||||
logger.info(f"Template info retrieved for {template_name}")
|
||||
|
||||
# Send response with the info
|
||||
TemplateHandlers.response_manager.send_response('template_info_result', True, {
|
||||
"info": info,
|
||||
"template_name": template_name,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting template info: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
TemplateHandlers.response_manager.send_response('template_info_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
17
backend/blender_cr8tive_engine/ws/utils/__init__.py
Normal file
17
backend/blender_cr8tive_engine/ws/utils/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Utility functions and singletons for WebSocket communication.
|
||||
"""
|
||||
|
||||
from .response_manager import ResponseManager
|
||||
from .session_manager import SessionManager
|
||||
|
||||
# Initialize singleton instances
|
||||
response_manager = ResponseManager.get_instance()
|
||||
session_manager = SessionManager.get_instance()
|
||||
|
||||
__all__ = [
|
||||
'ResponseManager',
|
||||
'SessionManager',
|
||||
'response_manager',
|
||||
'session_manager'
|
||||
]
|
79
backend/blender_cr8tive_engine/ws/utils/response_manager.py
Normal file
79
backend/blender_cr8tive_engine/ws/utils/response_manager.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Response manager for sending WebSocket responses.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResponseManager:
|
||||
"""
|
||||
Singleton class for managing WebSocket responses.
|
||||
Provides a centralized way to send responses without dependency on WebSocketHandler.
|
||||
"""
|
||||
_instance = None
|
||||
_websocket = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
logger.info("Creating new ResponseManager instance")
|
||||
cls._instance = super(ResponseManager, cls).__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
"""Get the singleton instance of ResponseManager"""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def set_websocket(self, websocket):
|
||||
"""Set the WebSocket connection to use for sending responses"""
|
||||
logger.info("Setting WebSocket connection in ResponseManager")
|
||||
self._websocket = websocket
|
||||
|
||||
def send_response(self, command, result, data=None, message_id=None):
|
||||
"""
|
||||
Send a WebSocket response.
|
||||
|
||||
:param command: The command to send (e.g., 'template_controls')
|
||||
:param result: Boolean indicating success or failure
|
||||
:param data: Optional additional data to send (e.g., controllables object)
|
||||
:param message_id: Optional message ID for tracking requests
|
||||
"""
|
||||
if not self._websocket:
|
||||
logger.error("Cannot send response: WebSocket not set")
|
||||
return False
|
||||
|
||||
logger.info(f"Preparing response for command: {command}")
|
||||
status = 'success' if result else 'failed'
|
||||
|
||||
response = {
|
||||
'command': command,
|
||||
'status': status
|
||||
}
|
||||
|
||||
# Include the data in the response if provided
|
||||
if data is not None:
|
||||
# Extract message_id if present in data
|
||||
if isinstance(data, dict) and 'message_id' in data:
|
||||
response['message_id'] = data['message_id']
|
||||
elif isinstance(data, dict) and 'data' in data and isinstance(data['data'], dict) and 'message_id' in data['data']:
|
||||
response['message_id'] = data['data']['message_id']
|
||||
|
||||
# Always keep data in its own field to maintain structure
|
||||
response['data'] = data
|
||||
|
||||
# Convert the response dictionary to a JSON string
|
||||
json_response = json.dumps(response)
|
||||
logger.info(f"Sending WebSocket response: {json_response}")
|
||||
|
||||
# Send the response via WebSocket
|
||||
try:
|
||||
self._websocket.send(json_response)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending WebSocket response: {e}")
|
||||
return False
|
39
backend/blender_cr8tive_engine/ws/utils/session_manager.py
Normal file
39
backend/blender_cr8tive_engine/ws/utils/session_manager.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Session manager for storing and retrieving session data across handlers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""
|
||||
Singleton class for managing session data.
|
||||
Provides centralized access to username and other session variables.
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
logger.info("Creating new SessionManager instance")
|
||||
cls._instance = super(SessionManager, cls).__new__(cls)
|
||||
cls._instance.username = None
|
||||
# Initialize other session data as needed
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
"""Get the singleton instance of SessionManager"""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def set_username(self, username):
|
||||
"""Set the current username"""
|
||||
logger.info(f"Setting session username: {username}")
|
||||
self.username = username
|
||||
|
||||
def get_username(self):
|
||||
"""Get the current username"""
|
||||
return self.username
|
411
backend/blender_cr8tive_engine/ws/websocket_handler.py
Normal file
411
backend/blender_cr8tive_engine/ws/websocket_handler.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
WebSocket handler implementation for blender_cr8tive_engine.
|
||||
This module provides the main WebSocket handler class with direct command routing.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import threading
|
||||
import logging
|
||||
import websocket
|
||||
import ssl
|
||||
import time
|
||||
import bpy
|
||||
from pathlib import Path
|
||||
from ..templates.template_wizard import TemplateWizard
|
||||
from ..core.blender_controllers import BlenderControllers
|
||||
from ..assets.asset_placer import AssetPlacer
|
||||
from .utils.session_manager import SessionManager
|
||||
from .utils.response_manager import ResponseManager
|
||||
|
||||
# Import handler classes
|
||||
from .handlers.animation import AnimationHandlers
|
||||
from .handlers.asset import AssetHandlers
|
||||
from .handlers.render import RenderHandlers
|
||||
from .handlers.scene import SceneHandlers
|
||||
from .handlers.template import TemplateHandlers
|
||||
from .handlers.camera import CameraHandlers
|
||||
from .handlers.light import LightHandlers
|
||||
from .handlers.material import MaterialHandlers
|
||||
from .handlers.object import ObjectHandlers
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
|
||||
def execute_in_main_thread(function, args):
|
||||
"""Execute a function in Blender's main thread"""
|
||||
def wrapper():
|
||||
function(*args)
|
||||
return None
|
||||
bpy.app.timers.register(wrapper, first_interval=0.0)
|
||||
|
||||
|
||||
class WebSocketHandler:
|
||||
"""WebSocket handler with direct command routing."""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if not cls._instance:
|
||||
cls._instance = super(WebSocketHandler, cls).__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
cls._instance.lock = threading.Lock()
|
||||
# Initialize without connection
|
||||
cls._instance.ws = None
|
||||
cls._instance.url = None # Start unconfigured
|
||||
cls._instance.username = None
|
||||
cls._instance._initialized = True # Mark as initialized
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
# Only initialize once
|
||||
if hasattr(self, 'command_handlers'):
|
||||
return
|
||||
|
||||
# Initialize components
|
||||
self.command_handlers = {}
|
||||
self.processing_complete = threading.Event()
|
||||
self.processed_commands = set()
|
||||
self.reconnect_attempts = 0
|
||||
self.max_retries = 5
|
||||
self.stop_retries = False
|
||||
self.ws_thread = None
|
||||
|
||||
# Register command handlers directly
|
||||
self._register_command_handlers()
|
||||
|
||||
logging.info(
|
||||
f"WebSocketHandler initialized with {len(self.command_handlers)} command handlers")
|
||||
|
||||
def _register_command_handlers(self):
|
||||
"""Register all command handlers directly."""
|
||||
# Utility commands
|
||||
self.command_handlers.update({
|
||||
'ping': self._handle_ping,
|
||||
'connection_confirmation': self._handle_connection_confirmation,
|
||||
})
|
||||
|
||||
# Animation commands
|
||||
self.command_handlers.update({
|
||||
'load_camera_animation': AnimationHandlers.handle_load_camera_animation,
|
||||
'load_light_animation': AnimationHandlers.handle_load_light_animation,
|
||||
'load_product_animation': AnimationHandlers.handle_load_product_animation,
|
||||
})
|
||||
|
||||
# Asset commands
|
||||
self.command_handlers.update({
|
||||
'append_asset': AssetHandlers.handle_append_asset,
|
||||
'remove_assets': AssetHandlers.handle_remove_assets,
|
||||
'swap_assets': AssetHandlers.handle_swap_assets,
|
||||
'rotate_assets': AssetHandlers.handle_rotate_assets,
|
||||
'scale_assets': AssetHandlers.handle_scale_assets,
|
||||
'get_asset_info': AssetHandlers.handle_get_asset_info,
|
||||
})
|
||||
|
||||
# Scene commands - using dedicated handlers instead of SceneHandlers
|
||||
self.command_handlers.update({
|
||||
'update_camera': CameraHandlers.handle_update_camera,
|
||||
'update_light': LightHandlers.handle_update_light,
|
||||
'update_material': MaterialHandlers.handle_update_material,
|
||||
'update_object': ObjectHandlers.handle_update_object,
|
||||
|
||||
# Keep legacy handlers for backward compatibility
|
||||
'change_camera': SceneHandlers.handle_camera_change,
|
||||
})
|
||||
|
||||
# Rendering commands
|
||||
self.command_handlers.update({
|
||||
'start_preview_rendering': RenderHandlers.handle_preview_rendering,
|
||||
'generate_video': RenderHandlers.handle_generate_video,
|
||||
})
|
||||
|
||||
# Template commands
|
||||
self.command_handlers.update({
|
||||
'get_template_controls': TemplateHandlers.handle_get_template_controls,
|
||||
'update_template_control': TemplateHandlers.handle_update_template_control,
|
||||
'get_template_info': TemplateHandlers.handle_get_template_info,
|
||||
})
|
||||
|
||||
def initialize_connection(self, url=None):
|
||||
"""Call this explicitly when ready to connect"""
|
||||
if self.ws:
|
||||
return # Already connected
|
||||
|
||||
# Get URL from environment or argument
|
||||
self.url = url or os.environ.get("WS_URL")
|
||||
|
||||
# Updated regex to handle both 'ws' and 'wss', local IPs, localhost, and production domains
|
||||
match = re.match(
|
||||
r'ws[s]?://([^:/]+)(?::\d+)?/ws/([^/]+)/blender', self.url)
|
||||
if match:
|
||||
# Extract host (local IP, localhost, or production domain)
|
||||
self.host = match.group(1)
|
||||
self.username = match.group(2) # Extract username
|
||||
|
||||
# Set username in SessionManager
|
||||
session_manager = SessionManager.get_instance()
|
||||
session_manager.set_username(self.username)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Invalid WebSocket URL format. Unable to extract username."
|
||||
)
|
||||
|
||||
if not self.url:
|
||||
raise ValueError(
|
||||
"WebSocket URL must be set via WS_URL environment variable "
|
||||
"or passed to initialize_connection()"
|
||||
)
|
||||
|
||||
# Initialize components only when needed
|
||||
self.ws = None
|
||||
self.wizard = TemplateWizard()
|
||||
self.controllers = BlenderControllers()
|
||||
self.asset_placer = AssetPlacer() # Initialize the asset placer
|
||||
|
||||
def connect(self, retries=5, delay=2):
|
||||
"""Establish WebSocket connection with retries and exponential backoff"""
|
||||
self.max_retries = retries
|
||||
self.reconnect_attempts = 0
|
||||
self.stop_retries = False
|
||||
|
||||
try:
|
||||
# Create SSL context with proper security settings
|
||||
ssl_context = ssl.create_default_context(
|
||||
purpose=ssl.Purpose.SERVER_AUTH,
|
||||
cafile="/home/thamsanqa/cloudflare_cert.crt"
|
||||
)
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_REQUIRED
|
||||
|
||||
# Verify the SSL context is properly configured
|
||||
logging.info(
|
||||
"SSL Context created with: verify_mode=CERT_REQUIRED, check_hostname=False")
|
||||
except Exception as ssl_error:
|
||||
logging.error(f"Failed to create SSL context: {ssl_error}")
|
||||
return False
|
||||
|
||||
while self.reconnect_attempts < retries and not self.stop_retries:
|
||||
try:
|
||||
if self.ws:
|
||||
self.ws.close()
|
||||
|
||||
# Use the environment-configured URL with SSL options
|
||||
self.ws = websocket.WebSocketApp(
|
||||
self.url,
|
||||
on_message=self._on_message,
|
||||
on_open=self._on_open,
|
||||
on_close=self._on_close,
|
||||
on_error=self._on_error,
|
||||
)
|
||||
|
||||
# Set WebSocket in ResponseManager
|
||||
response_manager = ResponseManager.get_instance()
|
||||
response_manager.set_websocket(self.ws)
|
||||
|
||||
self.processing_complete.clear()
|
||||
self.ws_thread = threading.Thread(
|
||||
target=lambda: self._run_websocket(ssl_context),
|
||||
daemon=True
|
||||
)
|
||||
self.ws_thread.start()
|
||||
|
||||
logging.info(f"WebSocket connection initialized to {self.url}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Connection to {self.url} failed: {e}, retrying in {delay} seconds..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
delay *= 2 # Exponential backoff
|
||||
self.reconnect_attempts += 1
|
||||
|
||||
logging.error(
|
||||
f"Max retries reached for {self.url}. Connection failed.")
|
||||
return False
|
||||
|
||||
def _run_websocket(self, ssl_context):
|
||||
"""Run WebSocket with SSL context"""
|
||||
while not self.processing_complete.is_set() and not self.stop_retries:
|
||||
try:
|
||||
self.ws.run_forever(
|
||||
sslopt={
|
||||
"cert_reqs": ssl_context.verify_mode,
|
||||
"check_hostname": ssl_context.check_hostname,
|
||||
"ssl_context": ssl_context
|
||||
}
|
||||
)
|
||||
except ssl.SSLError as ssl_err:
|
||||
logging.error(f"SSL Error in WebSocket connection: {ssl_err}")
|
||||
self.stop_retries = True
|
||||
break
|
||||
except websocket.WebSocketException as ws_err:
|
||||
logging.error(f"WebSocket error: {ws_err}")
|
||||
break
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in WebSocket connection: {e}")
|
||||
break
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect WebSocket"""
|
||||
with self.lock:
|
||||
self.processing_complete.set()
|
||||
self.stop_retries = True
|
||||
if self.ws and self.ws.sock and self.ws.sock.connected:
|
||||
self.ws.close()
|
||||
self.ws = None
|
||||
|
||||
if self.ws_thread:
|
||||
self.ws_thread.join(timeout=2)
|
||||
self.ws_thread = None
|
||||
|
||||
def _handle_ping(self, data):
|
||||
"""Handle ping command by responding with a pong"""
|
||||
message_id = data.get('message_id')
|
||||
response_manager = ResponseManager.get_instance()
|
||||
response_manager.send_response(
|
||||
"ping_result", True, {"pong": True}, message_id)
|
||||
logging.info(f"Responded to ping with message_id: {message_id}")
|
||||
|
||||
def _handle_connection_confirmation(self, data):
|
||||
"""Handle connection confirmation from CR8 Engine"""
|
||||
message_id = data.get('message_id')
|
||||
status = data.get('status')
|
||||
message = data.get('message')
|
||||
logging.info(
|
||||
f"Received connection confirmation: status={status}, message={message}")
|
||||
|
||||
# No need to respond, just acknowledge receipt
|
||||
if message_id:
|
||||
response_manager = ResponseManager.get_instance()
|
||||
response_manager.send_response(
|
||||
"connection_confirmation_result", True, {"acknowledged": True}, message_id)
|
||||
logging.info(
|
||||
f"Acknowledged connection confirmation with message_id: {message_id}")
|
||||
|
||||
def _on_open(self, ws):
|
||||
self.reconnect_attempts = 0
|
||||
|
||||
def send_init_message():
|
||||
try:
|
||||
# Use proper format for initialization message
|
||||
init_message = json.dumps({
|
||||
'command': 'connection_status',
|
||||
'status': 'Connected', # Capital C to match what CR8 Engine expects
|
||||
'message': 'Blender registered'
|
||||
})
|
||||
ws.send(init_message)
|
||||
logging.info("Connected Successfully")
|
||||
except Exception as e:
|
||||
logging.error(f"Error in _on_open: {e}")
|
||||
|
||||
execute_in_main_thread(send_init_message, ())
|
||||
|
||||
def _on_message(self, ws, message):
|
||||
self.process_message(message)
|
||||
|
||||
def process_message(self, message):
|
||||
try:
|
||||
logging.info(f"Processing incoming message: {message}")
|
||||
data = json.loads(message)
|
||||
command = data.get('command')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
logging.info(
|
||||
f"Parsed message - command: {command}, message_id: {message_id}")
|
||||
|
||||
# Prevent reprocessing of the same command
|
||||
if (command, message_id) in self.processed_commands:
|
||||
logging.warning(
|
||||
f"Skipping already processed command: {command} with message_id: {message_id}")
|
||||
return
|
||||
|
||||
if not command:
|
||||
logging.warning(
|
||||
f"Received message without a valid command: {data}")
|
||||
return
|
||||
|
||||
logging.info(f"Looking for handler for command: {command}")
|
||||
|
||||
# Get handler from registered handlers
|
||||
handler_method = self.command_handlers.get(command)
|
||||
|
||||
if handler_method:
|
||||
logging.info(
|
||||
f"Found handler for command {command}: {handler_method.__name__ if hasattr(handler_method, '__name__') else 'anonymous'}")
|
||||
|
||||
def execute_handler():
|
||||
handler_method(data)
|
||||
# Mark this command as processed
|
||||
self.processed_commands.add((command, message_id))
|
||||
logging.info(
|
||||
f"Marked command {command} with message_id {message_id} as processed")
|
||||
execute_in_main_thread(execute_handler, ())
|
||||
else:
|
||||
logging.warning(f"No handler found for command: {command}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logging.error(f"Error decoding JSON message: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing WebSocket message: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def _on_close(self, ws, close_status_code, close_msg):
|
||||
logging.info(
|
||||
f"WebSocket connection closed. Status: {close_status_code}, Message: {close_msg}")
|
||||
self.processing_complete.set()
|
||||
|
||||
def _on_error(self, ws, error):
|
||||
logging.error(f"WebSocket error: {error}")
|
||||
self.reconnect_attempts += 1
|
||||
if self.reconnect_attempts >= self.max_retries:
|
||||
logging.error("Max retries reached. Stopping WebSocket attempts.")
|
||||
self.stop_retries = True
|
||||
self.processing_complete.set()
|
||||
|
||||
def send_response(self, command, result, data=None, message_id=None):
|
||||
"""
|
||||
Send a WebSocket response using ResponseManager.
|
||||
This method is kept for compatibility during transition.
|
||||
"""
|
||||
response_manager = ResponseManager.get_instance()
|
||||
return response_manager.send_response(command, result, data, message_id)
|
||||
|
||||
|
||||
# Create a singleton instance for use in Blender
|
||||
def get_handler():
|
||||
"""Get the singleton WebSocketHandler instance."""
|
||||
return WebSocketHandler()
|
||||
|
||||
|
||||
# Register the operator for Blender
|
||||
class ConnectWebSocketOperator(bpy.types.Operator):
|
||||
bl_idname = "ws_handler.connect_websocket"
|
||||
bl_label = "Connect WebSocket"
|
||||
bl_description = "Initialize WebSocket connection to Cr8tive Engine server"
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
handler = get_handler()
|
||||
handler.initialize_connection() # Initialize before connecting
|
||||
if handler.connect():
|
||||
self.report({'INFO'}, f"Connected to {handler.url}")
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
self.report({'ERROR'}, "Connection failed")
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
|
||||
def register():
|
||||
"""Register WebSocket handler and operator"""
|
||||
bpy.utils.register_class(ConnectWebSocketOperator)
|
||||
|
||||
|
||||
def unregister():
|
||||
"""Unregister WebSocket handler and operator"""
|
||||
bpy.utils.unregister_class(ConnectWebSocketOperator)
|
||||
handler = get_handler()
|
||||
handler.disconnect()
|
@@ -1,709 +0,0 @@
|
||||
import os
|
||||
import bpy
|
||||
import re
|
||||
import json
|
||||
import threading
|
||||
import logging
|
||||
import websocket
|
||||
from .template_wizard import TemplateWizard
|
||||
from .blender_controllers import BlenderControllers
|
||||
from .video_generator import GenerateVideo
|
||||
from .asset_placer import AssetPlacer
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
import time # Add this if not already imported
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
|
||||
def execute_in_main_thread(function, args):
|
||||
"""Execute a function in Blender's main thread"""
|
||||
def wrapper():
|
||||
function(*args)
|
||||
return None
|
||||
bpy.app.timers.register(wrapper, first_interval=0.0)
|
||||
|
||||
|
||||
class WebSocketHandler:
|
||||
_instance = None
|
||||
|
||||
# Define the dispatch table mapping commands to handler functions
|
||||
command_handlers = {
|
||||
'change_camera': '_handle_camera_change',
|
||||
'update_light': '_handle_light_update',
|
||||
'update_material': '_handle_material_update',
|
||||
'update_object': '_handle_object_transformation',
|
||||
'start_preview_rendering': '_handle_preview_rendering',
|
||||
'generate_video': '_handle_generate_video',
|
||||
'rescan_template': '_handle_rescan_template',
|
||||
# Asset Placer commands
|
||||
'append_asset': '_handle_append_asset',
|
||||
'remove_assets': '_handle_remove_assets',
|
||||
'swap_assets': '_handle_swap_assets',
|
||||
'rotate_assets': '_handle_rotate_assets',
|
||||
'scale_assets': '_handle_scale_assets',
|
||||
'get_asset_info': '_handle_get_asset_info'
|
||||
}
|
||||
|
||||
def __new__(cls):
|
||||
if not cls._instance:
|
||||
cls._instance = super(WebSocketHandler, cls).__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
cls._instance.lock = threading.Lock()
|
||||
# Initialize without connection
|
||||
cls._instance.ws = None
|
||||
cls._instance.url = None # Start unconfigured
|
||||
cls._instance.username = None
|
||||
cls._instance._initialized = True # Mark as initialized
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
# Empty __init__ since we handle initialization in __new__
|
||||
pass
|
||||
|
||||
def initialize_connection(self, url=None):
|
||||
"""Call this explicitly when ready to connect"""
|
||||
if self.ws:
|
||||
return # Already connected
|
||||
|
||||
# Get URL from environment or argument
|
||||
self.url = url or os.environ.get("WS_URL")
|
||||
|
||||
# Updated regex to handle both 'ws' and 'wss', local IPs, localhost, and production domains
|
||||
match = re.match(
|
||||
r'ws[s]?://([^:/]+)(?::\d+)?/ws/([^/]+)/blender', self.url)
|
||||
if match:
|
||||
# Extract host (local IP, localhost, or production domain)
|
||||
self.host = match.group(1)
|
||||
self.username = match.group(2) # Extract username
|
||||
else:
|
||||
raise ValueError(
|
||||
"Invalid WebSocket URL format. Unable to extract username."
|
||||
)
|
||||
|
||||
if not self.url:
|
||||
raise ValueError(
|
||||
"WebSocket URL must be set via WS_URL environment variable "
|
||||
"or passed to initialize_connection()"
|
||||
)
|
||||
|
||||
# Initialize components only when needed
|
||||
self.ws = None
|
||||
self.wizard = TemplateWizard()
|
||||
self.controllers = BlenderControllers()
|
||||
self.asset_placer = AssetPlacer() # Initialize the asset placer
|
||||
self.processing_complete = threading.Event()
|
||||
self.processed_commands = set()
|
||||
self.reconnect_attempts = 0
|
||||
self.max_retries = 5
|
||||
self.stop_retries = False
|
||||
|
||||
def connect(self, retries=5, delay=2):
|
||||
"""Establish WebSocket connection with retries and exponential backoff"""
|
||||
self.max_retries = retries
|
||||
self.reconnect_attempts = 0
|
||||
self.stop_retries = False
|
||||
|
||||
try:
|
||||
# Create SSL context with proper security settings
|
||||
ssl_context = ssl.create_default_context(
|
||||
purpose=ssl.Purpose.SERVER_AUTH,
|
||||
cafile="/home/thamsanqa/cloudflare_cert.crt"
|
||||
)
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_REQUIRED
|
||||
|
||||
# Verify the SSL context is properly configured
|
||||
logging.info(
|
||||
"SSL Context created with: verify_mode=CERT_REQUIRED, check_hostname=False")
|
||||
except Exception as ssl_error:
|
||||
logging.error(f"Failed to create SSL context: {ssl_error}")
|
||||
return False
|
||||
|
||||
while self.reconnect_attempts < retries and not self.stop_retries:
|
||||
try:
|
||||
if self.ws:
|
||||
self.ws.close()
|
||||
|
||||
# Use the environment-configured URL with SSL options
|
||||
self.ws = websocket.WebSocketApp(
|
||||
self.url,
|
||||
on_message=self._on_message,
|
||||
on_open=self._on_open,
|
||||
on_close=self._on_close,
|
||||
on_error=self._on_error,
|
||||
)
|
||||
|
||||
self.processing_complete.clear()
|
||||
self.ws_thread = threading.Thread(
|
||||
target=lambda: self._run_websocket(ssl_context),
|
||||
daemon=True
|
||||
)
|
||||
self.ws_thread.start()
|
||||
|
||||
logging.info(f"WebSocket connection initialized to {self.url}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Connection to {self.url} failed: {e}, retrying in {delay} seconds..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
delay *= 2 # Exponential backoff
|
||||
self.reconnect_attempts += 1
|
||||
|
||||
logging.error(
|
||||
f"Max retries reached for {self.url}. Connection failed.")
|
||||
return False
|
||||
|
||||
def _run_websocket(self, ssl_context):
|
||||
"""Run WebSocket with SSL context"""
|
||||
while not self.processing_complete.is_set() and not self.stop_retries:
|
||||
try:
|
||||
self.ws.run_forever(
|
||||
sslopt={
|
||||
"cert_reqs": ssl_context.verify_mode,
|
||||
"check_hostname": ssl_context.check_hostname,
|
||||
"ssl_context": ssl_context
|
||||
}
|
||||
)
|
||||
except ssl.SSLError as ssl_err:
|
||||
logging.error(f"SSL Error in WebSocket connection: {ssl_err}")
|
||||
self.stop_retries = True
|
||||
break
|
||||
except websocket.WebSocketException as ws_err:
|
||||
logging.error(f"WebSocket error: {ws_err}")
|
||||
break
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in WebSocket connection: {e}")
|
||||
break
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect WebSocket"""
|
||||
with self.lock:
|
||||
self.processing_complete.set()
|
||||
self.stop_retries = True
|
||||
if self.ws and self.ws.sock and self.ws.sock.connected:
|
||||
self.ws.close()
|
||||
self.ws = None
|
||||
|
||||
if self.ws_thread:
|
||||
self.ws_thread.join(timeout=2)
|
||||
self.ws_thread = None
|
||||
|
||||
def _on_open(self, ws):
|
||||
self.reconnect_attempts = 0
|
||||
|
||||
def send_init_message():
|
||||
try:
|
||||
init_message = json.dumps({
|
||||
'status': 'Connected',
|
||||
})
|
||||
ws.send(init_message)
|
||||
logging.info("Connected Successfully")
|
||||
except Exception as e:
|
||||
logging.error(f"Error in _on_open: {e}")
|
||||
|
||||
execute_in_main_thread(send_init_message, ())
|
||||
|
||||
def _on_message(self, ws, message):
|
||||
self.process_message(message)
|
||||
|
||||
def process_message(self, message):
|
||||
try:
|
||||
logging.info(f"Processing incoming message: {message}")
|
||||
data = json.loads(message)
|
||||
command = data.get('command')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
logging.info(
|
||||
f"Parsed message - command: {command}, message_id: {message_id}")
|
||||
|
||||
# Prevent reprocessing of the same command
|
||||
if (command, message_id) in self.processed_commands:
|
||||
logging.warning(
|
||||
f"Skipping already processed command: {command} with message_id: {message_id}")
|
||||
return
|
||||
|
||||
if not command:
|
||||
logging.warning(
|
||||
f"Received message without a valid command: {data}")
|
||||
return
|
||||
|
||||
logging.info(f"Looking for handler for command: {command}")
|
||||
|
||||
# Optional: Add a retry limit or cooling period
|
||||
handler_method = getattr(
|
||||
self, self.command_handlers.get(command), None)
|
||||
|
||||
if handler_method:
|
||||
logging.info(
|
||||
f"Found handler for command {command}: {handler_method.__name__}")
|
||||
|
||||
def execute_handler():
|
||||
handler_method(data)
|
||||
# Mark this command as processed
|
||||
self.processed_commands.add((command, message_id))
|
||||
logging.info(
|
||||
f"Marked command {command} with message_id {message_id} as processed")
|
||||
execute_in_main_thread(execute_handler, ())
|
||||
else:
|
||||
logging.warning(f"No handler found for command: {command}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logging.error(f"Error decoding JSON message: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing WebSocket message: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Optional: Disconnect or reset to prevent further processing
|
||||
self.disconnect()
|
||||
|
||||
def _handle_camera_change(self, data):
|
||||
result = self.controllers.set_active_camera(data.get('camera_name'))
|
||||
self._send_response('camera_change_result', result)
|
||||
|
||||
def _handle_light_update(self, data):
|
||||
result = self.controllers.update_light(
|
||||
data.get('light_name'), color=data.get('color'), strength=data.get('strength'))
|
||||
self._send_response('light_update_result', result)
|
||||
|
||||
def _handle_material_update(self, data):
|
||||
result = self.controllers.update_material(
|
||||
data.get('material_name'),
|
||||
color=data.get('color'),
|
||||
roughness=data.get('roughness'),
|
||||
metallic=data.get('metallic')
|
||||
)
|
||||
self._send_response('material_update_result', result)
|
||||
|
||||
def _handle_object_transformation(self, data):
|
||||
result = self.controllers.update_object(
|
||||
data.get('object_name'), location=data.get('location'), rotation=data.get('rotation'), scale=data.get('scale'))
|
||||
self._send_response('object_transformation_result', result)
|
||||
|
||||
def _handle_preview_rendering(self, data):
|
||||
logging.info("Starting preview rendering")
|
||||
params = data.get('params', {})
|
||||
preview_renderer = self.controllers.create_preview_renderer(
|
||||
self.username)
|
||||
|
||||
try:
|
||||
# Process updates before rendering
|
||||
if 'camera' in params:
|
||||
camera_data = params['camera']
|
||||
result = self.controllers.set_active_camera(
|
||||
camera_data.get('camera_name'))
|
||||
logging.info(f"Camera update result: {result}")
|
||||
|
||||
if 'lights' in params:
|
||||
light_update = params['lights']
|
||||
result = self.controllers.update_light(
|
||||
light_update.get('light_name'),
|
||||
color=light_update.get('color'),
|
||||
strength=light_update.get('strength')
|
||||
)
|
||||
logging.info(f"Light update result: {result}")
|
||||
|
||||
if 'materials' in params:
|
||||
for material_update in params['materials']:
|
||||
result = self.controllers.update_material(
|
||||
material_update.get('material_name'),
|
||||
color=material_update.get('color'),
|
||||
roughness=material_update.get('roughness'),
|
||||
metallic=material_update.get('metallic')
|
||||
)
|
||||
logging.info(f"Material update result: {result}")
|
||||
|
||||
if 'objects' in params:
|
||||
for object_update in params['objects']:
|
||||
result = self.controllers.update_object(
|
||||
object_update.get('object_name'),
|
||||
location=object_update.get('location'),
|
||||
rotation=object_update.get('rotation'),
|
||||
scale=object_update.get('scale')
|
||||
)
|
||||
logging.info(f"Object update result: {result}")
|
||||
|
||||
# Cleanup any existing preview frames
|
||||
preview_renderer.cleanup()
|
||||
|
||||
# Setup preview render settings
|
||||
preview_renderer.setup_preview_render(params)
|
||||
|
||||
# Render the entire animation once
|
||||
bpy.ops.render.opengl(animation=True)
|
||||
|
||||
self._send_response('start_broadcast', True)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Preview rendering error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._send_response('Preview Rendering failed', False)
|
||||
|
||||
def _handle_generate_video(self, data):
|
||||
"""Generate video based on the available frames."""
|
||||
image_sequence_directory = Path(
|
||||
f"/mnt/shared_storage/Cr8tive_Engine/Sessions/{self.username}") / "preview"
|
||||
output_file = image_sequence_directory / "preview.mp4"
|
||||
resolution = (1280, 720)
|
||||
fps = 30
|
||||
|
||||
try:
|
||||
|
||||
# Include the message_id in your processing and response
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Ensure the directory exists
|
||||
image_sequence_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check if there are actually image files in the directory
|
||||
image_files = list(image_sequence_directory.glob('*.png'))
|
||||
if not image_files:
|
||||
raise ValueError(
|
||||
"No image files found in the specified directory")
|
||||
|
||||
# Initialize and execute the handler
|
||||
video_generator = GenerateVideo(
|
||||
str(image_sequence_directory),
|
||||
str(output_file),
|
||||
resolution,
|
||||
fps
|
||||
)
|
||||
video_generator.gen_video_from_images()
|
||||
|
||||
# Send success response with message_id
|
||||
self._send_response('generate_video', {
|
||||
"success": True,
|
||||
"status": "completed",
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
# Disconnect from the client if everything is successful
|
||||
self.disconnect()
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logging.error(
|
||||
f"Video generation error in directory {image_sequence_directory}: {error_message}"
|
||||
)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Send error response with message_id
|
||||
self._send_response('generate_video', {
|
||||
"success": False,
|
||||
"status": "failed",
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
logging.error(f"Video generation failed: {e}")
|
||||
|
||||
def _handle_rescan_template(self, data):
|
||||
"""Rescan the controllable objects and send the response"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logging.info(
|
||||
f"Handling template rescan request with message_id: {message_id}")
|
||||
|
||||
controllables = self.wizard.scan_controllable_objects()
|
||||
logging.info(f"Scanned {len(controllables)} controllable objects")
|
||||
|
||||
# Format the response with message_id and data
|
||||
result = {
|
||||
"data": {
|
||||
"controllables": controllables,
|
||||
"message_id": message_id # Include in data object
|
||||
}
|
||||
}
|
||||
logging.info(
|
||||
f"Sending template controls response with message_id: {message_id}")
|
||||
self._send_response('template_controls', True, result)
|
||||
logging.info(
|
||||
f"Successfully rescanned template with {len(controllables)} controllables")
|
||||
except Exception as e:
|
||||
logging.error(f"Error during template rescan: {e}")
|
||||
self._send_response('template_controls', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
def _handle_append_asset(self, data):
|
||||
"""Handle appending an asset to an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logging.info(
|
||||
f"Handling append asset request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
empty_name = data.get('empty_name')
|
||||
filepath = data.get('filepath')
|
||||
asset_name = data.get('asset_name')
|
||||
mode = data.get('mode', 'PLACE')
|
||||
scale_factor = data.get('scale_factor', 1.0)
|
||||
center_origin = data.get('center_origin', False)
|
||||
|
||||
# Call the asset placer to append the asset
|
||||
result = self.asset_placer.append_asset(
|
||||
empty_name,
|
||||
filepath,
|
||||
asset_name,
|
||||
mode=mode,
|
||||
scale_factor=scale_factor,
|
||||
center_origin=center_origin
|
||||
)
|
||||
|
||||
logging.info(f"Asset append result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
self._send_response('append_asset_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during asset append: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._send_response('append_asset_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
def _handle_remove_assets(self, data):
|
||||
"""Handle removing assets from an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logging.info(
|
||||
f"Handling remove assets request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
empty_name = data.get('empty_name')
|
||||
|
||||
# Call the asset placer to remove the assets
|
||||
result = self.asset_placer.remove_assets(empty_name)
|
||||
|
||||
logging.info(f"Asset removal result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
self._send_response('remove_assets_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during asset removal: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._send_response('remove_assets_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
def _handle_swap_assets(self, data):
|
||||
"""Handle swapping assets between two empties"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logging.info(
|
||||
f"Handling swap assets request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
empty1_name = data.get('empty1_name')
|
||||
empty2_name = data.get('empty2_name')
|
||||
|
||||
# Call the asset placer to swap the assets
|
||||
result = self.asset_placer.swap_assets(empty1_name, empty2_name)
|
||||
|
||||
logging.info(f"Asset swap result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
self._send_response('swap_assets_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during asset swap: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._send_response('swap_assets_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
def _handle_rotate_assets(self, data):
|
||||
"""Handle rotating assets on an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logging.info(
|
||||
f"Handling rotate assets request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
empty_name = data.get('empty_name')
|
||||
degrees = data.get('degrees')
|
||||
reset = data.get('reset', False)
|
||||
|
||||
# Call the asset placer to rotate the assets
|
||||
result = self.asset_placer.rotate_assets(
|
||||
empty_name, degrees, reset)
|
||||
|
||||
logging.info(f"Asset rotation result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
self._send_response('rotate_assets_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during asset rotation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._send_response('rotate_assets_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
def _handle_scale_assets(self, data):
|
||||
"""Handle scaling assets on an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logging.info(
|
||||
f"Handling scale assets request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
empty_name = data.get('empty_name')
|
||||
scale_percent = data.get('scale_percent')
|
||||
reset = data.get('reset', False)
|
||||
|
||||
# Call the asset placer to scale the assets
|
||||
result = self.asset_placer.scale_assets(
|
||||
empty_name, scale_percent, reset)
|
||||
|
||||
logging.info(f"Asset scaling result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
self._send_response('scale_assets_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during asset scaling: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._send_response('scale_assets_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
def _handle_get_asset_info(self, data):
|
||||
"""Handle getting information about assets on an empty"""
|
||||
try:
|
||||
message_id = data.get('message_id')
|
||||
logging.info(
|
||||
f"Handling get asset info request with message_id: {message_id}")
|
||||
|
||||
# Extract parameters from the request
|
||||
empty_name = data.get('empty_name')
|
||||
|
||||
# Call the asset placer to get the asset info
|
||||
result = self.asset_placer.get_asset_info(empty_name)
|
||||
|
||||
logging.info(f"Asset info result: {result}")
|
||||
|
||||
# Send response with the result
|
||||
self._send_response('asset_info_result', result.get('success', False), {
|
||||
"data": result,
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during get asset info: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._send_response('asset_info_result', False, {
|
||||
"message": str(e),
|
||||
"message_id": data.get('message_id')
|
||||
})
|
||||
|
||||
def _send_response(self, command, result, data=None, message_id=None):
|
||||
"""
|
||||
Send a WebSocket response.
|
||||
|
||||
:param command: The command to send (e.g., 'template_controls')
|
||||
:param result: Boolean indicating success or failure
|
||||
:param data: Optional additional data to send (e.g., controllables object)
|
||||
:param message_id: Optional message ID for tracking requests
|
||||
"""
|
||||
logging.info(f"Preparing response for command: {command}")
|
||||
status = 'success' if result else 'failed'
|
||||
|
||||
response = {
|
||||
'command': command,
|
||||
'status': status
|
||||
}
|
||||
|
||||
# Include the data in the response if provided
|
||||
if data is not None:
|
||||
# Extract message_id if present in data
|
||||
if isinstance(data, dict) and 'message_id' in data:
|
||||
response['message_id'] = data['message_id']
|
||||
elif isinstance(data, dict) and 'data' in data and isinstance(data['data'], dict) and 'message_id' in data['data']:
|
||||
response['message_id'] = data['data']['message_id']
|
||||
|
||||
# Always keep data in its own field to maintain structure
|
||||
response['data'] = data
|
||||
|
||||
# Convert the response dictionary to a JSON string
|
||||
json_response = json.dumps(response)
|
||||
logging.info(f"Sending WebSocket response: {json_response}")
|
||||
|
||||
# Send the response via WebSocket
|
||||
self.ws.send(json_response)
|
||||
|
||||
def _on_close(self, ws, close_status_code, close_msg):
|
||||
logging.info(
|
||||
f"WebSocket connection closed. Status: {close_status_code}, Message: {close_msg}")
|
||||
self.processing_complete.set()
|
||||
|
||||
def _on_error(self, ws, error):
|
||||
logging.error(f"WebSocket error: {error}")
|
||||
self.reconnect_attempts += 1
|
||||
if self.reconnect_attempts >= self.max_retries:
|
||||
logging.error("Max retries reached. Stopping WebSocket attempts.")
|
||||
self.stop_retries = True
|
||||
self.processing_complete.set()
|
||||
|
||||
|
||||
class ConnectWebSocketOperator(bpy.types.Operator):
|
||||
bl_idname = "ws_handler.connect_websocket"
|
||||
bl_label = "Connect WebSocket"
|
||||
bl_description = "Initialize WebSocket connection to Cr8tive Engine server"
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
handler = WebSocketHandler()
|
||||
handler.initialize_connection() # Initialize before connecting
|
||||
if handler.connect():
|
||||
self.report({'INFO'}, f"Connected to {handler.url}")
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
self.report({'ERROR'}, "Connection failed")
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
|
||||
def register():
|
||||
"""Register WebSocket handler and operator"""
|
||||
bpy.utils.register_class(ConnectWebSocketOperator)
|
||||
|
||||
|
||||
def unregister():
|
||||
"""Unregister WebSocket handler and operator"""
|
||||
bpy.utils.unregister_class(ConnectWebSocketOperator)
|
||||
websocket_handler.disconnect()
|
@@ -71,7 +71,7 @@ async def create_template(
|
||||
"constraints": light.get("constraints", [])
|
||||
}
|
||||
template_data["lights"].append(light_data)
|
||||
elif template_type == "product_animation":
|
||||
elif template_type == "product":
|
||||
# Simply use the templateData as is for product animations
|
||||
template_data = template_data_dict.get("templateData", {})
|
||||
else:
|
||||
@@ -164,3 +164,116 @@ async def list_templates(
|
||||
except Exception as e:
|
||||
print(f"Error in list_templates: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/animations", response_model=List[TemplateRead])
|
||||
async def list_animations(
|
||||
db: Client = Depends(get_db),
|
||||
logto_userId: Optional[str] = None,
|
||||
animation_type: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
List animations by type: camera, light, or product
|
||||
Returns minimal data without full templateData to reduce payload size
|
||||
"""
|
||||
try:
|
||||
# Start with base query - select only necessary fields for list view
|
||||
# Explicitly exclude templateData to reduce payload size
|
||||
query = db.table("template").select(
|
||||
"id,name,template_type,thumbnail,is_public,created_at,updated_at,creator_id")
|
||||
|
||||
# Filter by animation types
|
||||
animation_types = []
|
||||
if animation_type:
|
||||
if animation_type.lower() == "camera":
|
||||
animation_types.append("camera")
|
||||
elif animation_type.lower() == "light":
|
||||
animation_types.append("light")
|
||||
elif animation_type.lower() == "product":
|
||||
animation_types.append("product")
|
||||
else:
|
||||
# If no specific type is requested, include all animation types
|
||||
animation_types = ["camera", "light", "product"]
|
||||
|
||||
# Apply the filter for animation types
|
||||
if len(animation_types) == 1:
|
||||
query = query.eq("template_type", animation_types[0])
|
||||
else:
|
||||
query = query.in_("template_type", animation_types)
|
||||
|
||||
# Handle user-specific and public templates
|
||||
if logto_userId:
|
||||
user_data = db.table("user").select("id").eq(
|
||||
"logto_id", logto_userId).execute()
|
||||
if user_data.data and len(user_data.data) > 0:
|
||||
user_id = user_data.data[0]['id']
|
||||
query = query.filter('is_public', 'eq', True).filter(
|
||||
'creator_id', 'eq', user_id)
|
||||
else:
|
||||
query = query.eq("is_public", True)
|
||||
|
||||
# Execute the query
|
||||
result = query.execute()
|
||||
|
||||
if result.data is None:
|
||||
return []
|
||||
|
||||
# Convert to TemplateRead schema with empty templateData
|
||||
return [
|
||||
TemplateRead(
|
||||
id=template.get("id"),
|
||||
name=template.get("name"),
|
||||
template_type=template.get("template_type"),
|
||||
templateData={}, # Empty object since middleware will fetch full data when needed
|
||||
thumbnail=template.get("thumbnail"),
|
||||
compatible_templates=template.get("compatible_templates"),
|
||||
is_public=template.get("is_public"),
|
||||
created_at=template.get("created_at"),
|
||||
updated_at=template.get("updated_at"),
|
||||
creator_id=template.get("creator_id")
|
||||
) for template in result.data
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Error in list_animations: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/animations/{animation_id}", response_model=TemplateRead)
|
||||
async def get_animation(
|
||||
animation_id: str,
|
||||
db: Client = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get details for a specific animation
|
||||
"""
|
||||
try:
|
||||
result = db.table("template").select(
|
||||
"*").eq("id", animation_id).execute()
|
||||
|
||||
if not result.data or len(result.data) == 0:
|
||||
raise HTTPException(status_code=404, detail="Animation not found")
|
||||
|
||||
template = result.data[0]
|
||||
|
||||
# Check if it's an animation type
|
||||
if template.get("template_type") not in ["camera", "light", "product"]:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Specified ID is not an animation")
|
||||
|
||||
return TemplateRead(
|
||||
id=template.get("id"),
|
||||
name=template.get("name"),
|
||||
template_type=template.get("template_type"),
|
||||
templateData=template.get("templateData"),
|
||||
thumbnail=template.get("thumbnail"),
|
||||
compatible_templates=template.get("compatible_templates"),
|
||||
is_public=template.get("is_public"),
|
||||
created_at=template.get("created_at"),
|
||||
updated_at=template.get("updated_at"),
|
||||
creator_id=template.get("creator_id")
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error in get_animation: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
@@ -4,6 +4,23 @@ from .user import User
|
||||
|
||||
|
||||
class Asset(SQLModel, table=True):
|
||||
"""
|
||||
Represents an asset in the system.
|
||||
|
||||
Attributes:
|
||||
id (Optional[int]): The unique identifier of the asset.
|
||||
name (str): The name of the asset.
|
||||
description (Optional[str]): A description of the asset.
|
||||
asset_type (Optional[str]): The type of the asset.
|
||||
minio_path (str): The path to the asset in MinIO storage.
|
||||
creator_id (int): The ID of the user who created the asset.
|
||||
creator (User): The user who created the asset.
|
||||
price (Optional[float]): The price of the asset.
|
||||
is_public (bool): Whether the asset is public.
|
||||
controls (Dict[str, Any]): Scalable controls for the asset.
|
||||
project_assets (List[ProjectAsset]): The projects that the asset is used in.
|
||||
favorites (List[Favorite]): The users who have favorited the asset.
|
||||
"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
@@ -26,6 +43,18 @@ class Asset(SQLModel, table=True):
|
||||
|
||||
|
||||
class Favorite(SQLModel, table=True):
|
||||
"""
|
||||
Represents a user's favorite asset or template.
|
||||
|
||||
Attributes:
|
||||
id (Optional[int]): The unique identifier of the favorite.
|
||||
user_id (int): The ID of the user who favorited the asset or template.
|
||||
user (User): The user who favorited the asset or template.
|
||||
asset_id (Optional[int]): The ID of the asset that was favorited.
|
||||
template_id (Optional[int]): The ID of the template that was favorited.
|
||||
asset (Optional[Asset]): The asset that was favorited.
|
||||
template (Optional[Template]): The template that was favorited.
|
||||
"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
user: "User" = Relationship(back_populates="favorites")
|
||||
|
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
WebSocket module for cr8_engine.
|
||||
This module provides a modular approach to handling WebSocket communication.
|
||||
"""
|
||||
|
||||
# Import main components
|
||||
from .websocket_handler import WebSocketHandler
|
||||
|
||||
__all__ = [
|
||||
'WebSocketHandler'
|
||||
]
|
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Command handlers for WebSocket communication in cr8_engine.
|
||||
Each handler module contains related command handlers for a specific domain.
|
||||
"""
|
||||
|
||||
# Import handlers as they are implemented
|
||||
from .animation_handler import AnimationHandler
|
||||
from .asset_handler import AssetHandler
|
||||
from .template_handler import TemplateHandler
|
||||
from .preview_handler import PreviewHandler
|
||||
from .base_specialized_handler import BaseSpecializedHandler
|
||||
|
||||
# Will be populated as handlers are implemented
|
||||
__all__ = [
|
||||
'AnimationHandler',
|
||||
'AssetHandler',
|
||||
'TemplateHandler',
|
||||
'PreviewHandler',
|
||||
'BaseSpecializedHandler'
|
||||
]
|
@@ -0,0 +1,450 @@
|
||||
"""
|
||||
Animation command handlers for WebSocket communication in cr8_engine.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Dict, Any, Optional
|
||||
from .base_specialized_handler import BaseSpecializedHandler
|
||||
from app.db.session import get_db
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnimationHandler(BaseSpecializedHandler):
|
||||
"""Handlers for animation-related WebSocket commands."""
|
||||
|
||||
async def handle_load_camera_animation(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle loading camera animation with session awareness.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
# Only process requests from browser clients
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Handling load_camera_animation request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
animation_id = data.get('animation_id')
|
||||
empty_name = data.get('empty_name')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not animation_id:
|
||||
await self.send_error(username, 'load_camera_animation', 'Missing animation_id parameter', message_id)
|
||||
return
|
||||
|
||||
if not empty_name:
|
||||
await self.send_error(username, 'load_camera_animation', 'Missing empty_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Fetch animation data from database
|
||||
db = get_db()
|
||||
result = db.table("template").select(
|
||||
"*").eq("id", animation_id).execute()
|
||||
|
||||
if not result.data or len(result.data) == 0:
|
||||
await self.send_error(username, 'load_camera_animation', f'Animation {animation_id} not found', message_id)
|
||||
return
|
||||
|
||||
template = result.data[0]
|
||||
template_data = template.get("templateData", {})
|
||||
|
||||
# Prepare the message to forward to Blender
|
||||
blender_data = {
|
||||
'empty_name': empty_name,
|
||||
'template_data': template_data,
|
||||
'message_id': message_id
|
||||
}
|
||||
|
||||
try:
|
||||
# Forward to Blender using the base class method
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
"load_camera_animation",
|
||||
blender_data,
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'camera_animation_result',
|
||||
True,
|
||||
{
|
||||
'animation_id': animation_id,
|
||||
'empty_name': empty_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Camera animation request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'load_camera_animation', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling load_camera_animation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'load_camera_animation', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_load_light_animation(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle loading light animation with session awareness.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
# Only process requests from browser clients
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Handling load_light_animation request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
animation_id = data.get('animation_id')
|
||||
empty_name = data.get('empty_name')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not animation_id:
|
||||
await self.send_error(username, 'load_light_animation', 'Missing animation_id parameter', message_id)
|
||||
return
|
||||
|
||||
if not empty_name:
|
||||
await self.send_error(username, 'load_light_animation', 'Missing empty_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Fetch animation data from database
|
||||
db = get_db()
|
||||
result = db.table("template").select(
|
||||
"*").eq("id", animation_id).execute()
|
||||
|
||||
if not result.data or len(result.data) == 0:
|
||||
await self.send_error(username, 'load_light_animation', f'Animation {animation_id} not found', message_id)
|
||||
return
|
||||
|
||||
template = result.data[0]
|
||||
template_data = template.get("templateData", {})
|
||||
|
||||
# Prepare the message to forward to Blender
|
||||
blender_data = {
|
||||
'empty_name': empty_name,
|
||||
'template_data': template_data,
|
||||
'message_id': message_id
|
||||
}
|
||||
|
||||
try:
|
||||
# Forward to Blender using the base class method
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
"load_light_animation",
|
||||
blender_data,
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'light_animation_result',
|
||||
True,
|
||||
{
|
||||
'animation_id': animation_id,
|
||||
'empty_name': empty_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Light animation request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'load_light_animation', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling load_light_animation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'load_light_animation', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_load_product_animation(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle loading product animation with session awareness.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
# Only process requests from browser clients
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Handling load_product_animation request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
animation_id = data.get('animation_id')
|
||||
empty_name = data.get('empty_name')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not animation_id:
|
||||
await self.send_error(username, 'load_product_animation', 'Missing animation_id parameter', message_id)
|
||||
return
|
||||
|
||||
if not empty_name:
|
||||
await self.send_error(username, 'load_product_animation', 'Missing empty_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Fetch animation data from database
|
||||
db = get_db()
|
||||
result = db.table("template").select(
|
||||
"*").eq("id", animation_id).execute()
|
||||
|
||||
if not result.data or len(result.data) == 0:
|
||||
await self.send_error(username, 'load_product_animation', f'Animation {animation_id} not found', message_id)
|
||||
return
|
||||
|
||||
template = result.data[0]
|
||||
template_data = template.get("templateData", {})
|
||||
|
||||
# Prepare the message to forward to Blender
|
||||
blender_data = {
|
||||
'empty_name': empty_name,
|
||||
'template_data': template_data,
|
||||
'message_id': message_id
|
||||
}
|
||||
|
||||
try:
|
||||
# Forward to Blender using the base class method
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
"load_product_animation",
|
||||
blender_data,
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'product_animation_result',
|
||||
True,
|
||||
{
|
||||
'animation_id': animation_id,
|
||||
'empty_name': empty_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Product animation request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'load_product_animation', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling load_product_animation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'load_product_animation', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_get_animations(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle retrieving animations of a specific type with session awareness.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
# Only process requests from browser clients
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Handling get_animations request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
animation_type = data.get('animation_type')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not animation_type:
|
||||
await self.send_error(username, 'get_animations', 'Missing animation_type parameter', message_id)
|
||||
return
|
||||
|
||||
# Validate animation_type
|
||||
if animation_type not in ['camera', 'light', 'product']:
|
||||
await self.send_error(username, 'get_animations', f'Invalid animation_type: {animation_type}', message_id)
|
||||
return
|
||||
|
||||
# TODO: Implement the actual retrieval of animations from the database
|
||||
# For now, we'll just return some dummy animations
|
||||
|
||||
# Create dummy animations based on type
|
||||
animations = []
|
||||
if animation_type == 'camera':
|
||||
animations = [
|
||||
{
|
||||
'id': 'cam_anim_1',
|
||||
'name': 'Orbit Camera',
|
||||
'description': 'Camera orbits around the subject',
|
||||
'template_type': 'camera',
|
||||
'duration': 60,
|
||||
'templateData': {
|
||||
'name': 'orbit_camera',
|
||||
'focal_length': 50,
|
||||
'animation_data': {}
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'cam_anim_2',
|
||||
'name': 'Zoom In',
|
||||
'description': 'Camera zooms in on the subject',
|
||||
'template_type': 'camera',
|
||||
'duration': 30,
|
||||
'templateData': {
|
||||
'name': 'zoom_in',
|
||||
'focal_length': 35,
|
||||
'animation_data': {}
|
||||
}
|
||||
}
|
||||
]
|
||||
elif animation_type == 'light':
|
||||
animations = [
|
||||
{
|
||||
'id': 'light_anim_1',
|
||||
'name': 'Pulsing Light',
|
||||
'description': 'Light pulses on and off',
|
||||
'template_type': 'light',
|
||||
'duration': 45,
|
||||
'templateData': {
|
||||
'name': 'pulsing_light',
|
||||
'lights': [
|
||||
{
|
||||
'light_settings': {
|
||||
'type': 'POINT',
|
||||
'energy': 100
|
||||
},
|
||||
'animation_data': {}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'light_anim_2',
|
||||
'name': 'Color Shift',
|
||||
'description': 'Light changes color over time',
|
||||
'template_type': 'light',
|
||||
'duration': 60,
|
||||
'templateData': {
|
||||
'name': 'color_shift',
|
||||
'lights': [
|
||||
{
|
||||
'light_settings': {
|
||||
'type': 'POINT',
|
||||
'energy': 150
|
||||
},
|
||||
'animation_data': {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
elif animation_type == 'product':
|
||||
animations = [
|
||||
{
|
||||
'id': 'prod_anim_1',
|
||||
'name': 'Rotate Product',
|
||||
'description': 'Product rotates 360 degrees',
|
||||
'template_type': 'product_animation',
|
||||
'duration': 60,
|
||||
'templateData': {
|
||||
'name': 'rotate_product',
|
||||
'base_rotation_mode': 'XYZ',
|
||||
'animation_data': {
|
||||
'fcurves': []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'prod_anim_2',
|
||||
'name': 'Bounce Product',
|
||||
'description': 'Product bounces up and down',
|
||||
'template_type': 'product_animation',
|
||||
'duration': 45,
|
||||
'templateData': {
|
||||
'name': 'bounce_product',
|
||||
'base_rotation_mode': 'XYZ',
|
||||
'animation_data': {
|
||||
'fcurves': []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
# Send the animations back to the client
|
||||
await self.send_response(
|
||||
username,
|
||||
'get_animations_result',
|
||||
True,
|
||||
{
|
||||
'animations': animations,
|
||||
'animation_type': animation_type,
|
||||
'count': len(animations)
|
||||
},
|
||||
f"Retrieved {len(animations)} {animation_type} animations",
|
||||
message_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling get_animations: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'get_animations', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_animation_response(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle responses from animation-related commands.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The response data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
# Use the base class implementation for handling responses
|
||||
await self.handle_response(username, data, client_type)
|
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
Asset operation handlers for WebSocket communication in cr8_engine.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
from .base_specialized_handler import BaseSpecializedHandler
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AssetHandler(BaseSpecializedHandler):
|
||||
"""Handlers for asset-related WebSocket commands."""
|
||||
|
||||
async def handle_append_asset(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""Handle appending an asset to an empty"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(f"Handling append_asset request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
empty_name = data.get('empty_name')
|
||||
asset_name = data.get('asset_name')
|
||||
message_id = data.get('message_id')
|
||||
filepath = data.get('filepath')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not empty_name:
|
||||
await self.send_error(username, 'append_asset', 'Missing empty_name parameter', message_id)
|
||||
return
|
||||
|
||||
if not asset_name:
|
||||
await self.send_error(username, 'append_asset', 'Missing asset_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
'append_asset',
|
||||
{
|
||||
'empty_name': empty_name,
|
||||
'asset_name': asset_name,
|
||||
'filepath': filepath,
|
||||
# Add mode parameter with default 'PLACE'
|
||||
'mode': data.get('mode', 'PLACE'),
|
||||
# Also forward scale_factor
|
||||
'scale_factor': data.get('scale_factor', 1.0),
|
||||
# And center_origin
|
||||
'center_origin': data.get('center_origin', False)
|
||||
},
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'append_asset_result',
|
||||
True,
|
||||
{
|
||||
'empty_name': empty_name,
|
||||
'asset_name': asset_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Asset append request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'append_asset', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling append_asset: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'append_asset', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_remove_assets(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""Handle removing assets from an empty"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(f"Handling remove_assets request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
empty_name = data.get('empty_name')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not empty_name:
|
||||
await self.send_error(username, 'remove_assets', 'Missing empty_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
'remove_assets',
|
||||
{
|
||||
'empty_name': empty_name
|
||||
},
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'remove_assets_result',
|
||||
True,
|
||||
{
|
||||
'empty_name': empty_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Asset removal request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'remove_assets', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling remove_assets: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'remove_assets', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_swap_assets(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""Handle swapping assets between two empties"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(f"Handling swap_assets request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
empty1_name = data.get('empty1_name')
|
||||
empty2_name = data.get('empty2_name')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not empty1_name:
|
||||
await self.send_error(username, 'swap_assets', 'Missing empty1_name parameter', message_id)
|
||||
return
|
||||
|
||||
if not empty2_name:
|
||||
await self.send_error(username, 'swap_assets', 'Missing empty2_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
'swap_assets',
|
||||
{
|
||||
'empty1_name': empty1_name,
|
||||
'empty2_name': empty2_name
|
||||
},
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'swap_assets_result',
|
||||
True,
|
||||
{
|
||||
'empty1_name': empty1_name,
|
||||
'empty2_name': empty2_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Asset swap request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'swap_assets', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling swap_assets: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'swap_assets', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_rotate_assets(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""Handle rotating assets on an empty"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(f"Handling rotate_assets request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
empty_name = data.get('empty_name')
|
||||
degrees = data.get('degrees')
|
||||
reset = data.get('reset', False)
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not empty_name:
|
||||
await self.send_error(username, 'rotate_assets', 'Missing empty_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
'rotate_assets',
|
||||
{
|
||||
'empty_name': empty_name,
|
||||
'degrees': degrees,
|
||||
'reset': reset
|
||||
},
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'rotate_assets_result',
|
||||
True,
|
||||
{
|
||||
'empty_name': empty_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Asset rotation request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'rotate_assets', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling rotate_assets: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'rotate_assets', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_scale_assets(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""Handle scaling assets on an empty"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(f"Handling scale_assets request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
empty_name = data.get('empty_name')
|
||||
scale_percent = data.get('scale_percent')
|
||||
reset = data.get('reset', False)
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not empty_name:
|
||||
await self.send_error(username, 'scale_assets', 'Missing empty_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
'scale_assets',
|
||||
{
|
||||
'empty_name': empty_name,
|
||||
'scale_percent': scale_percent,
|
||||
'reset': reset
|
||||
},
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'scale_assets_result',
|
||||
True,
|
||||
{
|
||||
'empty_name': empty_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Asset scaling request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'scale_assets', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling scale_assets: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'scale_assets', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_get_asset_info(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""Handle getting asset info from an empty"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Handling get_asset_info request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
empty_name = data.get('empty_name')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not empty_name:
|
||||
await self.send_error(username, 'get_asset_info', 'Missing empty_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
'get_asset_info',
|
||||
{
|
||||
'empty_name': empty_name
|
||||
},
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'get_asset_info_result',
|
||||
True,
|
||||
{
|
||||
'empty_name': empty_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Asset info request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'get_asset_info', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling get_asset_info: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'get_asset_info', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_asset_operation_response(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle responses from asset-related commands.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The response data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
# Use the base class implementation for handling responses
|
||||
await self.handle_response(username, data, client_type)
|
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Base class for specialized WebSocket handlers in cr8_engine.
|
||||
This module provides common functionality for all specialized handlers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class BaseSpecializedHandler:
|
||||
"""Base class for specialized WebSocket handlers."""
|
||||
|
||||
def __init__(self, session_manager):
|
||||
"""
|
||||
Initialize the specialized handler.
|
||||
|
||||
Args:
|
||||
session_manager: The session manager instance
|
||||
"""
|
||||
self.session_manager = session_manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def forward_to_blender(self, username: str, command: str, data: Dict[str, Any], message_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Forward a command to the connected Blender client.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
command: The command to forward
|
||||
data: The data to forward
|
||||
message_id: The message ID for tracking
|
||||
|
||||
Returns:
|
||||
The session object for further use
|
||||
|
||||
Raises:
|
||||
ValueError: If Blender client is not connected
|
||||
"""
|
||||
session = self.session_manager.get_session(username)
|
||||
|
||||
# Check if session exists and Blender is connected
|
||||
if not session:
|
||||
raise ValueError("No active session found")
|
||||
|
||||
if not session.blender_socket or session.blender_socket_closed:
|
||||
# Special handling for template controls - queue the request
|
||||
if command == "get_template_controls":
|
||||
# Queue the request for later processing
|
||||
request_data = {**data, 'command': command}
|
||||
if message_id:
|
||||
request_data['message_id'] = message_id
|
||||
|
||||
# Add to pending template requests
|
||||
if not hasattr(session, 'pending_template_requests'):
|
||||
session.pending_template_requests = []
|
||||
|
||||
session.pending_template_requests.append(request_data)
|
||||
|
||||
self.logger.info(
|
||||
f"Queued {command} for {username} until Blender connects")
|
||||
|
||||
# Return session without raising error
|
||||
return session
|
||||
else:
|
||||
# For other commands, raise the error
|
||||
raise ValueError("Blender client not connected")
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Add to pending requests for tracking
|
||||
self.session_manager.add_pending_request(username, message_id)
|
||||
|
||||
# Prepare the message with message_id for tracking
|
||||
blender_message = {**data, 'command': command,
|
||||
'message_id': message_id}
|
||||
|
||||
try:
|
||||
# Send to Blender
|
||||
await session.blender_socket.send_json(blender_message)
|
||||
self.logger.info(f"Forwarded {command} to Blender for {username}")
|
||||
except Exception as e:
|
||||
# Mark socket as closed if send fails
|
||||
session.blender_socket_closed = True
|
||||
self.logger.error(f"Error forwarding to Blender: {str(e)}")
|
||||
raise ValueError(f"Failed to communicate with Blender: {str(e)}")
|
||||
|
||||
# Return the session for further use
|
||||
return session
|
||||
|
||||
async def send_response(self, username: str, command: str, success: bool, data: Dict[str, Any], message: Optional[str] = None, message_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
Send a response to the browser client.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
command: The command to respond to
|
||||
success: Whether the operation was successful
|
||||
data: The data to send
|
||||
message: An optional message
|
||||
message_id: The message ID for tracking
|
||||
"""
|
||||
session = self.session_manager.get_session(username)
|
||||
if not session or not session.browser_socket or session.browser_socket_closed:
|
||||
self.logger.warning(
|
||||
f"Cannot send response to {username}: No browser socket or socket is closed")
|
||||
return
|
||||
|
||||
# Format and send response
|
||||
response = {
|
||||
'command': command,
|
||||
'status': 'success' if success else 'error',
|
||||
'data': data
|
||||
}
|
||||
|
||||
if message:
|
||||
response['message'] = message
|
||||
|
||||
if message_id:
|
||||
response['message_id'] = message_id
|
||||
|
||||
try:
|
||||
await session.browser_socket.send_json(response)
|
||||
self.logger.debug(f"Sent {command} response to {username}")
|
||||
except Exception as e:
|
||||
session.browser_socket_closed = True
|
||||
self.logger.error(f"Error sending response to browser: {str(e)}")
|
||||
|
||||
async def send_error(self, username: str, command: str, error_message: str, message_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
Send an error response to the browser client.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
command: The command that failed
|
||||
error_message: The error message
|
||||
message_id: The message ID for tracking
|
||||
"""
|
||||
await self.send_response(
|
||||
username,
|
||||
f"{command}_result",
|
||||
False,
|
||||
{'error': error_message},
|
||||
error_message,
|
||||
message_id
|
||||
)
|
||||
self.logger.error(
|
||||
f"Error in {command} for {username}: {error_message}")
|
||||
|
||||
async def handle_response(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle a response from Blender.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The response data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
if client_type != "blender":
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract message_id
|
||||
message_id = data.get('message_id')
|
||||
if not message_id:
|
||||
self.logger.error("No message_id in response")
|
||||
return
|
||||
|
||||
# Find requesting user
|
||||
request_username = self.session_manager.get_pending_request(
|
||||
message_id)
|
||||
if not request_username:
|
||||
self.logger.error(
|
||||
f"No pending request for message_id {message_id}")
|
||||
return
|
||||
|
||||
# Get session
|
||||
session = self.session_manager.get_session(request_username)
|
||||
if not session or not session.browser_socket or session.browser_socket_closed:
|
||||
self.logger.error(
|
||||
f"No valid session or browser socket for {request_username}")
|
||||
# Clean up the pending request even if we can't forward the response
|
||||
self.session_manager.remove_pending_request(message_id)
|
||||
return
|
||||
|
||||
# Forward response to browser
|
||||
try:
|
||||
await session.browser_socket.send_json(data)
|
||||
self.logger.info(
|
||||
f"Forwarded response to browser for {request_username}")
|
||||
except Exception as e:
|
||||
session.browser_socket_closed = True
|
||||
self.logger.error(
|
||||
f"Error forwarding response to browser: {str(e)}")
|
||||
|
||||
# Clean up
|
||||
self.session_manager.remove_pending_request(message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling response: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Camera command handlers for WebSocket communication in cr8_engine.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
from .base_specialized_handler import BaseSpecializedHandler
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CameraHandler(BaseSpecializedHandler):
|
||||
"""Handlers for camera-related WebSocket commands."""
|
||||
|
||||
async def handle_update_camera(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle updating camera with session awareness.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(f"Handling update_camera request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
camera_name = data.get('camera_name')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not camera_name:
|
||||
await self.send_error(username, 'update_camera', 'Missing camera_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
'update_camera',
|
||||
{
|
||||
'camera_name': camera_name
|
||||
},
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'update_camera_result',
|
||||
True,
|
||||
{
|
||||
'camera_name': camera_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Camera update request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'update_camera', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling update_camera: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'update_camera', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_camera_response(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle responses from camera-related commands.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The response data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
# Use the base class implementation for handling responses
|
||||
await self.handle_response(username, data, client_type)
|
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Light command handlers for WebSocket communication in cr8_engine.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
from .base_specialized_handler import BaseSpecializedHandler
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LightHandler(BaseSpecializedHandler):
|
||||
"""Handlers for light-related WebSocket commands."""
|
||||
|
||||
async def handle_update_light(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle updating light with session awareness.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(f"Handling update_light request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
light_name = data.get('light_name')
|
||||
color = data.get('color')
|
||||
strength = data.get('strength')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not light_name:
|
||||
await self.send_error(username, 'update_light', 'Missing light_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
'update_light',
|
||||
{
|
||||
'light_name': light_name,
|
||||
'color': color,
|
||||
'strength': strength
|
||||
},
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'update_light_result',
|
||||
True,
|
||||
{
|
||||
'light_name': light_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Light update request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'update_light', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling update_light: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'update_light', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_light_response(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle responses from light-related commands.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The response data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
# Use the base class implementation for handling responses
|
||||
await self.handle_response(username, data, client_type)
|
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Material command handlers for WebSocket communication in cr8_engine.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
from .base_specialized_handler import BaseSpecializedHandler
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaterialHandler(BaseSpecializedHandler):
|
||||
"""Handlers for material-related WebSocket commands."""
|
||||
|
||||
async def handle_update_material(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle updating material with session awareness.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Handling update_material request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
material_name = data.get('material_name')
|
||||
color = data.get('color')
|
||||
roughness = data.get('roughness')
|
||||
metallic = data.get('metallic')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not material_name:
|
||||
await self.send_error(username, 'update_material', 'Missing material_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
'update_material',
|
||||
{
|
||||
'material_name': material_name,
|
||||
'color': color,
|
||||
'roughness': roughness,
|
||||
'metallic': metallic
|
||||
},
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'update_material_result',
|
||||
True,
|
||||
{
|
||||
'material_name': material_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Material update request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'update_material', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling update_material: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'update_material', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_material_response(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle responses from material-related commands.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The response data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
# Use the base class implementation for handling responses
|
||||
await self.handle_response(username, data, client_type)
|
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Object command handlers for WebSocket communication in cr8_engine.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
from .base_specialized_handler import BaseSpecializedHandler
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ObjectHandler(BaseSpecializedHandler):
|
||||
"""Handlers for object-related WebSocket commands."""
|
||||
|
||||
async def handle_update_object(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle updating object with session awareness.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(f"Handling update_object request from {username}")
|
||||
|
||||
# Extract parameters
|
||||
object_name = data.get('object_name')
|
||||
location = data.get('location')
|
||||
rotation = data.get('rotation')
|
||||
scale = data.get('scale')
|
||||
message_id = data.get('message_id')
|
||||
|
||||
# Generate message_id if not provided
|
||||
if not message_id:
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Validate required parameters
|
||||
if not object_name:
|
||||
await self.send_error(username, 'update_object', 'Missing object_name parameter', message_id)
|
||||
return
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
'update_object',
|
||||
{
|
||||
'object_name': object_name,
|
||||
'location': location,
|
||||
'rotation': rotation,
|
||||
'scale': scale
|
||||
},
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'update_object_result',
|
||||
True,
|
||||
{
|
||||
'object_name': object_name,
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Object update request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'update_object', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling update_object: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'update_object', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_object_response(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle responses from object-related commands.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The response data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
# Use the base class implementation for handling responses
|
||||
await self.handle_response(username, data, client_type)
|
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Preview and rendering handlers for WebSocket communication in cr8_engine.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from .base_specialized_handler import BaseSpecializedHandler
|
||||
from app.core.config import settings
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PreviewHandler(BaseSpecializedHandler):
|
||||
"""Handlers for preview-related WebSocket commands."""
|
||||
|
||||
def __init__(self, session_manager):
|
||||
"""
|
||||
Initialize the preview handler.
|
||||
|
||||
Args:
|
||||
session_manager: The session manager instance
|
||||
"""
|
||||
super().__init__(session_manager)
|
||||
|
||||
def _get_preview_dir(self, username: str):
|
||||
"""
|
||||
Get the preview directory for a specific user.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
|
||||
Returns:
|
||||
Path: The path to the user's preview directory
|
||||
"""
|
||||
# Define the base directory where previews are stored
|
||||
base_preview_dir = Path(settings.BLENDER_RENDER_PREVIEW_DIRECTORY)
|
||||
|
||||
# Create a user-specific directory dynamically
|
||||
user_preview_dir = base_preview_dir / username / "preview"
|
||||
|
||||
# Ensure the directory exists
|
||||
user_preview_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
return user_preview_dir
|
||||
|
||||
async def handle_preview_rendering(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle preview rendering requests.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Handling preview rendering request from {username}")
|
||||
|
||||
# Generate message_id
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
# Add to pending requests for tracking
|
||||
self.session_manager.add_pending_request(username, message_id)
|
||||
|
||||
# Forward to Blender
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
'start_preview_rendering',
|
||||
{
|
||||
'params': data.get('params', {})
|
||||
},
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'preview_rendering_result',
|
||||
True,
|
||||
{
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
"Preview rendering started",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'preview_rendering', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling preview rendering: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'preview_rendering', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_generate_video(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle video generation requests.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Handling generate video request from {username}")
|
||||
|
||||
# Generate message_id
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
# Forward to Blender
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
'generate_video',
|
||||
data,
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'generate_video_result',
|
||||
True,
|
||||
{
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
"Video generation started",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'generate_video', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling generate video: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'generate_video', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_start_broadcast(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle starting frame broadcasting.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Handling start broadcast request from {username}")
|
||||
|
||||
session = self.session_manager.get_session(username)
|
||||
if not session:
|
||||
self.logger.error(f"No session found for {username}")
|
||||
return
|
||||
|
||||
# Do not reset last_frame_index; resume from where it left off
|
||||
session.should_broadcast = True
|
||||
|
||||
# Create new task only if none exists or previous completed
|
||||
if not hasattr(session, 'broadcast_task') or session.broadcast_task.done():
|
||||
session.broadcast_task = asyncio.create_task(
|
||||
self._broadcast_frames(username)
|
||||
)
|
||||
|
||||
# Notify client
|
||||
if session.browser_socket:
|
||||
await session.browser_socket.send_json({
|
||||
"status": "OK",
|
||||
"message": "Frame broadcast started/resumed"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling start broadcast: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'start_broadcast', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_stop_broadcast(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle stopping frame broadcasting.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Handling stop broadcast request from {username}")
|
||||
|
||||
session = self.session_manager.get_session(username)
|
||||
if not session:
|
||||
self.logger.error(f"No session found for {username}")
|
||||
return
|
||||
|
||||
# Set flag to break broadcast loop
|
||||
session.should_broadcast = False
|
||||
|
||||
# Cancel task if running
|
||||
if hasattr(session, 'broadcast_task'):
|
||||
if not session.broadcast_task.done():
|
||||
session.broadcast_task.cancel()
|
||||
try:
|
||||
await session.broadcast_task # Handle cleanup
|
||||
except asyncio.CancelledError:
|
||||
self.logger.info(f"Broadcast stopped for {username}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping broadcast: {e}")
|
||||
|
||||
# Notify client
|
||||
if session.browser_socket:
|
||||
await session.browser_socket.send_json({
|
||||
"status": "OK",
|
||||
"message": "Frame broadcast stopped"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling stop broadcast: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'stop_broadcast', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def _broadcast_frames(self, username: str):
|
||||
"""
|
||||
Broadcast frames once (stops after last frame).
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
"""
|
||||
session = self.session_manager.get_session(username)
|
||||
if not session or not session.browser_socket:
|
||||
return
|
||||
|
||||
# Get the preview directory for this user
|
||||
preview_dir = self._get_preview_dir(username)
|
||||
|
||||
try:
|
||||
frames = sorted(preview_dir.glob("frame_*.png"))
|
||||
if not frames:
|
||||
return
|
||||
|
||||
# Start from the frame after the last frame that was sent
|
||||
start_frame_index = session.last_frame_index + \
|
||||
1 if session.last_frame_index is not None else 0
|
||||
|
||||
# Broadcast frames sequentially (no automatic looping)
|
||||
for frame_index in range(start_frame_index, len(frames)):
|
||||
frame = frames[frame_index]
|
||||
|
||||
if not session.should_broadcast: # Check pause/stop flag
|
||||
break
|
||||
|
||||
try:
|
||||
with open(frame, "rb") as img_file:
|
||||
img_str = base64.b64encode(img_file.read()).decode()
|
||||
await session.browser_socket.send_json({
|
||||
"type": "frame",
|
||||
"data": img_str,
|
||||
"frame_index": frame_index,
|
||||
})
|
||||
session.last_frame_index = frame_index # Track progress
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error sending frame: {e}")
|
||||
session.should_broadcast = False
|
||||
return
|
||||
|
||||
await asyncio.sleep(0.033) # ~30 FPS
|
||||
|
||||
# Notify client the broadcast finished (only if it completed fully)
|
||||
if session.last_frame_index == len(frames) - 1:
|
||||
await session.browser_socket.send_json({
|
||||
"type": "broadcast_complete"
|
||||
})
|
||||
# Reset last_frame_index only after the last frame has been sent
|
||||
session.last_frame_index = -1
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self.logger.info(f"Broadcast cancelled for {username}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Broadcast error: {e}")
|
||||
finally:
|
||||
session.should_broadcast = False # Ensure broadcast stops
|
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Template control handlers for WebSocket communication in cr8_engine.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
from .base_specialized_handler import BaseSpecializedHandler
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateHandler(BaseSpecializedHandler):
|
||||
"""Handlers for template-related WebSocket commands."""
|
||||
|
||||
async def handle_get_template_controls(self, username: str, data: Dict[str, Any], client_type: str, command: str = None) -> None:
|
||||
"""Handle getting template controls"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Handling get_template_controls request from {username}")
|
||||
|
||||
# Generate message_id
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.debug(f"Generated message_id: {message_id}")
|
||||
|
||||
# Forward the command to Blender
|
||||
try:
|
||||
# Use the command parameter instead of hardcoding 'rescan_template'
|
||||
cmd = command if command else 'get_template_controls'
|
||||
session = await self.forward_to_blender(
|
||||
username,
|
||||
cmd,
|
||||
{},
|
||||
message_id
|
||||
)
|
||||
|
||||
# Send confirmation to browser
|
||||
await self.send_response(
|
||||
username,
|
||||
'template_controls_result',
|
||||
True,
|
||||
{
|
||||
'status': 'forwarded_to_blender'
|
||||
},
|
||||
f"Template controls request forwarded to Blender",
|
||||
message_id
|
||||
)
|
||||
|
||||
except ValueError as ve:
|
||||
await self.send_error(username, 'get_template_controls', str(ve), message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling get_template_controls: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await self.send_error(username, 'get_template_controls', f"Error: {str(e)}", data.get('message_id'))
|
||||
|
||||
async def handle_template_controls_response(self, username: str, data: Dict[str, Any], client_type: str) -> None:
|
||||
"""
|
||||
Handle responses from template-related commands.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The response data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
# Use the base class implementation for handling responses
|
||||
await self.handle_response(username, data, client_type)
|
@@ -2,6 +2,7 @@
|
||||
from typing import Dict, Optional
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from app.services.blender_service import BlenderService
|
||||
|
||||
@@ -23,11 +24,15 @@ class Session:
|
||||
self.should_broadcast = False
|
||||
self.last_frame_index = -1
|
||||
self.pending_requests: Dict[str, str] = {} # message_id -> username
|
||||
self.pending_template_requests = [] # Queued template control requests
|
||||
self.last_connection_attempt = 0 # timestamp of last connection attempt
|
||||
self.connection_attempts = 0 # number of connection attempts
|
||||
# maximum number of connection attempts before giving up
|
||||
self.max_connection_attempts = 5
|
||||
self.base_retry_delay = 1 # base delay in seconds between retries
|
||||
# Track socket states
|
||||
self.browser_socket_closed = False
|
||||
self.blender_socket_closed = False
|
||||
|
||||
|
||||
class SessionManager:
|
||||
@@ -40,12 +45,61 @@ class SessionManager:
|
||||
try:
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
|
||||
# Check if we already have a session for this user
|
||||
if username in self.sessions:
|
||||
session = self.sessions[username]
|
||||
if session.is_active:
|
||||
# Update browser socket regardless
|
||||
old_socket = session.browser_socket
|
||||
session.browser_socket = websocket
|
||||
session.browser_socket_closed = False
|
||||
self.logger.info(
|
||||
f"Updated browser socket for {username} (refresh detected)")
|
||||
|
||||
# Check if Blender is actually running
|
||||
blender_running = await BlenderService.check_instance_status(username)
|
||||
blender_socket_alive = session.blender_socket and not session.blender_socket_closed
|
||||
|
||||
if blender_running:
|
||||
if session.state == SessionState.CONNECTED and blender_socket_alive:
|
||||
# Best case: Blender is running and connected - just notify browser
|
||||
await websocket.send_json({
|
||||
"type": "system",
|
||||
"status": "blender_connected",
|
||||
"message": "Reconnected to existing Blender instance"
|
||||
})
|
||||
self.logger.info(
|
||||
f"Browser reconnected to existing Blender for {username}")
|
||||
return session
|
||||
elif session.state == SessionState.WAITING_FOR_BLENDER:
|
||||
# Blender is running but not connected to WS - wait for reconnect
|
||||
await websocket.send_json({
|
||||
"type": "system",
|
||||
"status": "waiting_for_blender",
|
||||
"message": "Waiting for Blender to reconnect..."
|
||||
})
|
||||
self.logger.info(
|
||||
f"Waiting for Blender WS reconnection for {username}")
|
||||
return session
|
||||
else:
|
||||
# Blender is running but in unexpected state - reset to allow browser_ready
|
||||
session.state = "waiting_for_browser_ready"
|
||||
session.blend_file = blend_file
|
||||
self.logger.info(
|
||||
f"Blender running but in state {session.state} for {username}, resetting")
|
||||
return session
|
||||
else:
|
||||
# Blender is not running but session exists - reset to allow new launch
|
||||
session.state = "waiting_for_browser_ready"
|
||||
session.blend_file = blend_file
|
||||
session.blender_socket = None
|
||||
session.blender_socket_closed = True
|
||||
self.logger.info(
|
||||
f"Resetting session for {username} - Blender not running")
|
||||
return session
|
||||
|
||||
# Original reconnection logic for non-refresh cases
|
||||
if session.state == SessionState.CONNECTED:
|
||||
# Update the browser socket
|
||||
session.browser_socket = websocket
|
||||
return session
|
||||
else:
|
||||
# Check if we should allow a new connection attempt
|
||||
@@ -53,9 +107,14 @@ class SessionManager:
|
||||
retry_delay = min(
|
||||
session.base_retry_delay * (2 ** session.connection_attempts), 60)
|
||||
|
||||
if time_since_last_attempt < retry_delay:
|
||||
# Calculate wait time with a minimum of 1 second to avoid "0 seconds" message
|
||||
wait_time = max(
|
||||
1, int(retry_delay - time_since_last_attempt))
|
||||
|
||||
# Only enforce delay if it's significant (more than 1 second)
|
||||
if time_since_last_attempt < retry_delay and wait_time > 1:
|
||||
raise ValueError(
|
||||
f"Please wait {int(retry_delay - time_since_last_attempt)} seconds before reconnecting")
|
||||
f"Please wait {wait_time} seconds before reconnecting")
|
||||
|
||||
if session.connection_attempts >= session.max_connection_attempts:
|
||||
raise ValueError(
|
||||
@@ -67,21 +126,25 @@ class SessionManager:
|
||||
# Create new session with updated connection tracking
|
||||
session = Session(username, browser_socket=websocket)
|
||||
session.last_connection_attempt = current_time
|
||||
session.connection_attempts = 0 if not username in self.sessions else self.sessions[
|
||||
username].connection_attempts + 1
|
||||
# Store blend_file for later use when launching Blender
|
||||
session.blend_file = blend_file
|
||||
|
||||
# Reset connection attempts for new sessions or increment for retries
|
||||
if not username in self.sessions:
|
||||
session.connection_attempts = 0
|
||||
else:
|
||||
# Only increment if the previous session wasn't fully connected
|
||||
prev_session = self.sessions[username]
|
||||
if prev_session.state != SessionState.CONNECTED:
|
||||
session.connection_attempts = prev_session.connection_attempts + 1
|
||||
else:
|
||||
# If previous session was fully connected, reset counter
|
||||
session.connection_attempts = 0
|
||||
|
||||
self.sessions[username] = session
|
||||
|
||||
# Launch Blender instance using the service
|
||||
success = await BlenderService.launch_instance(username, blend_file)
|
||||
if not success:
|
||||
await self.cleanup_session(username)
|
||||
raise ValueError("Failed to launch Blender instance")
|
||||
|
||||
# Set state to waiting for Blender connection
|
||||
session.state = SessionState.WAITING_FOR_BLENDER
|
||||
|
||||
# Start timeout monitor for Blender connection
|
||||
asyncio.create_task(self._monitor_blender_connection(username))
|
||||
# Set state to waiting for browser ready signal instead of launching Blender immediately
|
||||
session.state = "waiting_for_browser_ready"
|
||||
|
||||
return session
|
||||
|
||||
@@ -101,12 +164,62 @@ class SessionManager:
|
||||
if session.state != SessionState.WAITING_FOR_BLENDER:
|
||||
raise ValueError(f"Unexpected Blender connection for {username}")
|
||||
|
||||
# Verify connection is still open with a proper command ping and wait for response
|
||||
try:
|
||||
ping_message_id = str(uuid.uuid4())
|
||||
await websocket.send_json({
|
||||
"command": "ping",
|
||||
"message_id": ping_message_id
|
||||
})
|
||||
|
||||
# Create a future to wait for the ping response
|
||||
ping_response_future = asyncio.Future()
|
||||
|
||||
# Define a response handler
|
||||
async def receive_ping_response():
|
||||
try:
|
||||
# Wait for the ping response with a timeout
|
||||
response_data = await asyncio.wait_for(websocket.receive_json(), timeout=5.0)
|
||||
|
||||
# Check if this is the ping response we're waiting for
|
||||
if (response_data.get("command") == "ping_result" and
|
||||
response_data.get("status") == "success" and
|
||||
response_data.get("data", {}).get("pong") == True):
|
||||
ping_response_future.set_result(True)
|
||||
else:
|
||||
# Handle other messages that might come in first
|
||||
self.logger.debug(
|
||||
f"Received non-ping response during verification: {response_data}")
|
||||
# Recursively wait for the next message
|
||||
await receive_ping_response()
|
||||
except asyncio.TimeoutError:
|
||||
ping_response_future.set_exception(
|
||||
ValueError("Ping response timeout"))
|
||||
except Exception as e:
|
||||
ping_response_future.set_exception(e)
|
||||
|
||||
# Start listening for the response
|
||||
asyncio.create_task(receive_ping_response())
|
||||
|
||||
# Wait for the ping response
|
||||
await ping_response_future
|
||||
self.logger.info(f"Ping verification successful for {username}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Blender connection verification failed: {str(e)}")
|
||||
raise ValueError(f"Blender connection unstable: {str(e)}")
|
||||
|
||||
# Register the Blender socket
|
||||
session.blender_socket = websocket
|
||||
session.blender_socket_closed = False
|
||||
session.state = SessionState.CONNECTED
|
||||
|
||||
# Reset connection attempts counter on successful connection
|
||||
session.connection_attempts = 0
|
||||
|
||||
# Notify browser client that Blender is connected
|
||||
if session.browser_socket:
|
||||
if session.browser_socket and not session.browser_socket_closed:
|
||||
try:
|
||||
await session.browser_socket.send_json({
|
||||
"type": "system",
|
||||
@@ -114,9 +227,27 @@ class SessionManager:
|
||||
"message": "Blender instance connected successfully"
|
||||
})
|
||||
except Exception as e:
|
||||
session.browser_socket_closed = True
|
||||
self.logger.error(
|
||||
f"Error notifying browser of Blender connection: {str(e)}")
|
||||
|
||||
# Process any queued template control requests
|
||||
if session.pending_template_requests:
|
||||
self.logger.info(
|
||||
f"Processing {len(session.pending_template_requests)} queued template requests")
|
||||
from app.realtime_engine.websockets.websocket_handler import WebSocketHandler
|
||||
handler = WebSocketHandler(self, username)
|
||||
|
||||
for request in session.pending_template_requests:
|
||||
try:
|
||||
await handler.handle_message(username, {"command": "get_template_controls"}, "browser")
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error processing queued template request: {str(e)}")
|
||||
|
||||
# Clear the queue
|
||||
session.pending_template_requests = []
|
||||
|
||||
self.logger.info(f"Blender client registered for session {username}")
|
||||
|
||||
async def _monitor_blender_connection(self, username: str):
|
||||
@@ -163,27 +294,40 @@ class SessionManager:
|
||||
session.is_active = False
|
||||
|
||||
# Close WebSocket connections
|
||||
if session.browser_socket:
|
||||
if session.browser_socket and not session.browser_socket_closed:
|
||||
try:
|
||||
await session.browser_socket.send_json({
|
||||
"type": "system",
|
||||
"status": "session_closed",
|
||||
"message": "Session terminated"
|
||||
})
|
||||
# Only try to send if socket appears to be open
|
||||
try:
|
||||
await session.browser_socket.send_json({
|
||||
"type": "system",
|
||||
"status": "session_closed",
|
||||
"message": "Session terminated"
|
||||
})
|
||||
except Exception:
|
||||
# If send fails, just log and continue with close
|
||||
self.logger.debug(
|
||||
"Could not send session_closed message")
|
||||
|
||||
await session.browser_socket.close()
|
||||
session.browser_socket_closed = True
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error closing browser socket: {str(e)}")
|
||||
|
||||
if session.blender_socket:
|
||||
if session.blender_socket and not session.blender_socket_closed:
|
||||
try:
|
||||
await session.blender_socket.close()
|
||||
session.blender_socket_closed = True
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error closing blender socket: {str(e)}")
|
||||
|
||||
# Terminate Blender instance
|
||||
await BlenderService.terminate_instance(username)
|
||||
try:
|
||||
await BlenderService.terminate_instance(username)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error terminating Blender instance: {str(e)}")
|
||||
|
||||
# Remove session
|
||||
del self.sessions[username]
|
||||
@@ -195,17 +339,56 @@ class SessionManager:
|
||||
raise ValueError(f"No session found for {from_username}")
|
||||
|
||||
session = self.sessions[from_username]
|
||||
|
||||
# Special handling for template control requests when Blender is not connected
|
||||
if message.get("command") == "get_template_controls" and (
|
||||
session.state != SessionState.CONNECTED or
|
||||
not session.blender_socket or
|
||||
session.blender_socket_closed
|
||||
):
|
||||
# Queue the request for later processing
|
||||
self.logger.info(
|
||||
f"Queuing template control request for {from_username}")
|
||||
session.pending_template_requests.append(message)
|
||||
|
||||
# Notify browser that request is queued
|
||||
if session.browser_socket and not session.browser_socket_closed:
|
||||
try:
|
||||
await session.browser_socket.send_json({
|
||||
"type": "system",
|
||||
"status": "waiting_for_blender",
|
||||
"message": "Template controls request queued until Blender connects"
|
||||
})
|
||||
except Exception as e:
|
||||
session.browser_socket_closed = True
|
||||
self.logger.error(
|
||||
f"Error sending queue notification: {str(e)}")
|
||||
|
||||
return
|
||||
|
||||
if session.state != SessionState.CONNECTED:
|
||||
raise ValueError(
|
||||
f"Session not fully connected for {from_username}")
|
||||
|
||||
target_socket = session.blender_socket if target == "blender" else session.browser_socket
|
||||
socket_closed = session.blender_socket_closed if target == "blender" else session.browser_socket_closed
|
||||
|
||||
if target_socket:
|
||||
await target_socket.send_json(message)
|
||||
if target_socket and not socket_closed:
|
||||
try:
|
||||
await target_socket.send_json(message)
|
||||
except Exception as e:
|
||||
# Mark socket as closed if send fails
|
||||
if target == "blender":
|
||||
session.blender_socket_closed = True
|
||||
else:
|
||||
session.browser_socket_closed = True
|
||||
self.logger.error(
|
||||
f"Error forwarding message to {target}: {str(e)}")
|
||||
raise ValueError(
|
||||
f"Failed to forward message to {target}: {str(e)}")
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"No {target} socket found for {from_username}")
|
||||
f"No {target} socket found for {from_username} or socket is closed")
|
||||
|
||||
async def handle_disconnect(self, username: str, client_type: str):
|
||||
"""Handle client disconnection"""
|
||||
@@ -214,6 +397,7 @@ class SessionManager:
|
||||
|
||||
if client_type == "browser":
|
||||
session.browser_socket = None
|
||||
session.browser_socket_closed = True
|
||||
session.last_connection_attempt = asyncio.get_event_loop().time()
|
||||
session.connection_attempts += 1
|
||||
|
||||
@@ -228,17 +412,18 @@ class SessionManager:
|
||||
|
||||
# Otherwise wait briefly before cleanup to allow reconnection
|
||||
await asyncio.sleep(retry_delay)
|
||||
if username in self.sessions and not session.browser_socket:
|
||||
if username in self.sessions and (not session.browser_socket or session.browser_socket_closed):
|
||||
await self.cleanup_session(username)
|
||||
else: # blender
|
||||
session.blender_socket = None
|
||||
session.blender_socket_closed = True
|
||||
session.state = SessionState.DISCONNECTED
|
||||
|
||||
# Reset connection attempts when Blender disconnects
|
||||
session.connection_attempts = 0
|
||||
|
||||
# Notify browser if it's still connected
|
||||
if session.browser_socket:
|
||||
if session.browser_socket and not session.browser_socket_closed:
|
||||
try:
|
||||
await session.browser_socket.send_json({
|
||||
"type": "system",
|
||||
@@ -247,12 +432,13 @@ class SessionManager:
|
||||
"shouldReconnect": True
|
||||
})
|
||||
except Exception as e:
|
||||
session.browser_socket_closed = True
|
||||
self.logger.error(
|
||||
f"Error notifying browser of Blender disconnect: {str(e)}")
|
||||
|
||||
# Don't immediately clean up - give time for reconnection
|
||||
await asyncio.sleep(5)
|
||||
if username in self.sessions and not session.blender_socket:
|
||||
if username in self.sessions and (not session.blender_socket or session.blender_socket_closed):
|
||||
await self.cleanup_session(username)
|
||||
|
||||
def get_session(self, username: str) -> Optional[Session]:
|
||||
@@ -274,6 +460,31 @@ class SessionManager:
|
||||
return session.pending_requests[message_id]
|
||||
return None
|
||||
|
||||
async def launch_blender_for_session(self, username: str):
|
||||
"""Launch Blender for a session that's ready"""
|
||||
session = self.get_session(username)
|
||||
if not session:
|
||||
raise ValueError(f"No session found for {username}")
|
||||
|
||||
if session.state != "waiting_for_browser_ready":
|
||||
raise ValueError(
|
||||
f"Session for {username} is not waiting for browser ready signal")
|
||||
|
||||
# Launch Blender instance using the service
|
||||
success = await BlenderService.launch_instance(username, session.blend_file)
|
||||
if not success:
|
||||
await self.cleanup_session(username)
|
||||
raise ValueError("Failed to launch Blender instance")
|
||||
|
||||
# Set state to waiting for Blender connection
|
||||
session.state = SessionState.WAITING_FOR_BLENDER
|
||||
|
||||
# Start timeout monitor for Blender connection
|
||||
asyncio.create_task(self._monitor_blender_connection(username))
|
||||
|
||||
self.logger.info(
|
||||
f"Launched Blender instance for {username} with file {session.blend_file}")
|
||||
|
||||
def remove_pending_request(self, message_id: str) -> None:
|
||||
"""Remove a pending request from its session"""
|
||||
for session in self.sessions.values():
|
||||
|
@@ -1,46 +1,73 @@
|
||||
# app/websockets/websocket_handler.py
|
||||
import asyncio
|
||||
"""
|
||||
WebSocket handler implementation for cr8_engine.
|
||||
This module provides the main WebSocket handler class that uses the modular architecture.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import base64
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
from fastapi import WebSocket
|
||||
from app.core.config import settings
|
||||
|
||||
# Import specialized handlers
|
||||
from .handlers.animation_handler import AnimationHandler
|
||||
from .handlers.asset_handler import AssetHandler
|
||||
from .handlers.template_handler import TemplateHandler
|
||||
from .handlers.preview_handler import PreviewHandler
|
||||
from .handlers.camera_handler import CameraHandler
|
||||
from .handlers.light_handler import LightHandler
|
||||
from .handlers.material_handler import MaterialHandler
|
||||
from .handlers.object_handler import ObjectHandler
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketHandler:
|
||||
"""Handles WebSocket message processing with session-based architecture"""
|
||||
"""WebSocket handler that uses the modular architecture."""
|
||||
|
||||
def __init__(self, session_manager, username: str):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
"""
|
||||
Initialize the WebSocket handler.
|
||||
|
||||
Args:
|
||||
session_manager: The session manager instance
|
||||
username: The username for this handler
|
||||
"""
|
||||
self.session_manager = session_manager
|
||||
self.username = username
|
||||
self.preview_dir = self._get_preview_dir()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def _get_preview_dir(self):
|
||||
# Define the base directory where previews are stored
|
||||
base_preview_dir = Path(settings.BLENDER_RENDER_PREVIEW_DIRECTORY)
|
||||
# Initialize specialized handlers
|
||||
self.animation_handler = AnimationHandler(session_manager)
|
||||
self.asset_handler = AssetHandler(session_manager)
|
||||
self.template_handler = TemplateHandler(session_manager)
|
||||
self.preview_handler = PreviewHandler(session_manager)
|
||||
self.camera_handler = CameraHandler(session_manager)
|
||||
self.light_handler = LightHandler(session_manager)
|
||||
self.material_handler = MaterialHandler(session_manager)
|
||||
self.object_handler = ObjectHandler(session_manager)
|
||||
|
||||
# Create a user-specific directory dynamically
|
||||
user_preview_dir = base_preview_dir / self.username / "preview"
|
||||
|
||||
# Ensure the directory exists
|
||||
user_preview_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
return user_preview_dir
|
||||
# Initialize logger
|
||||
logger.info(f"WebSocketHandler initialized for user {username}")
|
||||
|
||||
async def handle_message(self, username: str, data: Dict[str, Any], client_type: str):
|
||||
"""
|
||||
Process incoming WebSocket messages with proper routing
|
||||
Process incoming WebSocket messages with proper routing.
|
||||
|
||||
Args:
|
||||
username: The username of the client
|
||||
data: The message data
|
||||
client_type: The type of client (browser or blender)
|
||||
"""
|
||||
try:
|
||||
command = data.get("command")
|
||||
action = data.get("action")
|
||||
status = data.get("status")
|
||||
|
||||
if status == "Connected":
|
||||
self.logger.info(f"Client {username} connected")
|
||||
# Handle connection status messages (case-insensitive check)
|
||||
if status and status.lower() == "connected" or command == "connection_status":
|
||||
self.logger.info(
|
||||
f"Client {username} connected with status: {status}")
|
||||
session = self.session_manager.get_session(username)
|
||||
if session and session.browser_socket:
|
||||
retry_delay = min(
|
||||
@@ -55,50 +82,125 @@ class WebSocketHandler:
|
||||
})
|
||||
return
|
||||
|
||||
# Handle browser ready signal
|
||||
if command == "browser_ready" and client_type == "browser":
|
||||
try:
|
||||
# Check if this is a recovery attempt
|
||||
recovery_mode = data.get("recovery", False)
|
||||
|
||||
if recovery_mode:
|
||||
# In recovery mode, reset session state if needed
|
||||
session = self.session_manager.get_session(username)
|
||||
if session and session.state != "waiting_for_browser_ready":
|
||||
# Force session into the correct state for recovery
|
||||
session.state = "waiting_for_browser_ready"
|
||||
self.logger.info(
|
||||
f"Resetting session state for {username} in recovery mode")
|
||||
|
||||
# Then continue with normal launch logic
|
||||
await self.session_manager.launch_blender_for_session(username)
|
||||
|
||||
# Notify browser that Blender is being launched
|
||||
session = self.session_manager.get_session(username)
|
||||
if session and session.browser_socket:
|
||||
await session.browser_socket.send_json({
|
||||
"type": "system",
|
||||
"status": "launching_blender",
|
||||
"message": "Launching Blender instance"
|
||||
})
|
||||
self.logger.info(
|
||||
f"Browser ready signal received from {username}, launching Blender")
|
||||
except ValueError as e:
|
||||
self.logger.error(f"Error launching Blender: {str(e)}")
|
||||
# Notify browser of the error
|
||||
session = self.session_manager.get_session(username)
|
||||
if session and session.browser_socket:
|
||||
await session.browser_socket.send_json({
|
||||
"type": "system",
|
||||
"status": "error",
|
||||
"message": f"Failed to launch Blender: {str(e)}"
|
||||
})
|
||||
return
|
||||
|
||||
# Command completion handler
|
||||
if status == "completed":
|
||||
await self._handle_command_completion(username, data)
|
||||
return
|
||||
|
||||
handlers = {
|
||||
"start_preview_rendering": self._handle_preview_rendering,
|
||||
"stop_broadcast": self._handle_stop_broadcast,
|
||||
"start_broadcast": self._handle_start_broadcast,
|
||||
"generate_video": self._handle_generate_video,
|
||||
"get_template_controls": self._handle_get_template_controls,
|
||||
"template_controls": self._handle_template_controls_response,
|
||||
# Asset Placer handlers
|
||||
"append_asset": self._handle_asset_operation,
|
||||
"remove_assets": self._handle_asset_operation,
|
||||
"swap_assets": self._handle_asset_operation,
|
||||
"rotate_assets": self._handle_asset_operation,
|
||||
"scale_assets": self._handle_asset_operation,
|
||||
"get_asset_info": self._handle_asset_operation
|
||||
}
|
||||
|
||||
# Check for asset operation responses
|
||||
asset_response_commands = [
|
||||
"append_asset_result",
|
||||
"remove_assets_result",
|
||||
"swap_assets_result",
|
||||
"rotate_assets_result",
|
||||
"scale_assets_result",
|
||||
"asset_info_result"
|
||||
]
|
||||
|
||||
if command in asset_response_commands:
|
||||
await self._handle_asset_operation_response(username, data, client_type)
|
||||
# Check for animation-related commands
|
||||
if (command and command.startswith('load_') and command.endswith('_animation')) or command == 'get_animations':
|
||||
await self._route_to_animation_handler(username, data, client_type, command)
|
||||
return
|
||||
|
||||
handler = handlers.get(command) # Try command first
|
||||
if not handler:
|
||||
# Fall back to action if no command handler
|
||||
handler = handlers.get(action)
|
||||
# Check for asset-related commands
|
||||
if command in ['append_asset', 'remove_assets', 'swap_assets',
|
||||
'rotate_assets', 'scale_assets', 'get_asset_info']:
|
||||
await self._route_to_asset_handler(username, data, client_type, command)
|
||||
return
|
||||
|
||||
if handler:
|
||||
await handler(username, data, client_type)
|
||||
else:
|
||||
self.logger.warning(f"Unhandled message: {data}")
|
||||
# Check for animation-related responses
|
||||
if command and 'animation' in command and '_result' in command:
|
||||
await self.animation_handler.handle_animation_response(username, data, client_type)
|
||||
return
|
||||
|
||||
# Check for asset-related responses
|
||||
if command and 'asset' in command and '_result' in command:
|
||||
await self.asset_handler.handle_asset_operation_response(username, data, client_type)
|
||||
return
|
||||
|
||||
# Check for template-related commands
|
||||
if command == 'get_template_controls':
|
||||
await self._route_to_template_handler(username, data, client_type, command)
|
||||
return
|
||||
|
||||
# Check for template-related responses
|
||||
if command and 'template' in command and '_result' in command:
|
||||
await self.template_handler.handle_template_controls_response(username, data, client_type)
|
||||
return
|
||||
|
||||
# Check for scene control commands
|
||||
if command == 'update_camera':
|
||||
await self.camera_handler.handle_update_camera(username, data, client_type)
|
||||
return
|
||||
|
||||
if command == 'update_light':
|
||||
await self.light_handler.handle_update_light(username, data, client_type)
|
||||
return
|
||||
|
||||
if command == 'update_material':
|
||||
await self.material_handler.handle_update_material(username, data, client_type)
|
||||
return
|
||||
|
||||
if command == 'update_object':
|
||||
await self.object_handler.handle_update_object(username, data, client_type)
|
||||
return
|
||||
|
||||
# Check for scene control responses
|
||||
if command and '_result' in command:
|
||||
if 'camera' in command:
|
||||
await self.camera_handler.handle_camera_response(username, data, client_type)
|
||||
return
|
||||
elif 'light' in command:
|
||||
await self.light_handler.handle_light_response(username, data, client_type)
|
||||
return
|
||||
elif 'material' in command:
|
||||
await self.material_handler.handle_material_response(username, data, client_type)
|
||||
return
|
||||
elif 'object' in command:
|
||||
await self.object_handler.handle_object_response(username, data, client_type)
|
||||
return
|
||||
|
||||
# Check for preview-related commands
|
||||
if command in ['start_preview_rendering', 'stop_broadcast',
|
||||
'start_broadcast', 'generate_video']:
|
||||
await self._route_to_preview_handler(username, data, client_type, command)
|
||||
return
|
||||
|
||||
# If no handler found, forward the message to the other client type
|
||||
target = "blender" if client_type == "browser" else "browser"
|
||||
await self.session_manager.forward_message(username, data, target)
|
||||
self.logger.info(
|
||||
f"Forwarded message from {client_type} to {target}: {command or action}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Message processing error: {str(e)}")
|
||||
@@ -111,286 +213,47 @@ class WebSocketHandler:
|
||||
"message": f"Message processing error: {str(e)}"
|
||||
})
|
||||
|
||||
async def _handle_preview_rendering(self, username: str, data: Dict[str, Any], client_type: str):
|
||||
"""Handle preview rendering requests"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
async def _route_to_animation_handler(self, username: str, data: Dict[str, Any], client_type: str, command: str):
|
||||
"""Route animation-related commands to the animation handler"""
|
||||
if command == 'load_camera_animation':
|
||||
await self.animation_handler.handle_load_camera_animation(username, data, client_type)
|
||||
elif command == 'load_light_animation':
|
||||
await self.animation_handler.handle_load_light_animation(username, data, client_type)
|
||||
elif command == 'load_product_animation':
|
||||
await self.animation_handler.handle_load_product_animation(username, data, client_type)
|
||||
elif command == 'get_animations':
|
||||
await self.animation_handler.handle_get_animations(username, data, client_type)
|
||||
|
||||
message_id = str(uuid.uuid4())
|
||||
session = self.session_manager.get_session(username)
|
||||
self.session_manager.add_pending_request(username, message_id)
|
||||
if not session or not session.blender_socket:
|
||||
await self._send_error(username, "Blender client not connected")
|
||||
return
|
||||
async def _route_to_asset_handler(self, username: str, data: Dict[str, Any], client_type: str, command: str):
|
||||
"""Route asset-related commands to the asset handler"""
|
||||
if command == 'append_asset':
|
||||
await self.asset_handler.handle_append_asset(username, data, client_type)
|
||||
elif command == 'remove_assets':
|
||||
await self.asset_handler.handle_remove_assets(username, data, client_type)
|
||||
elif command == 'swap_assets':
|
||||
await self.asset_handler.handle_swap_assets(username, data, client_type)
|
||||
elif command == 'rotate_assets':
|
||||
await self.asset_handler.handle_rotate_assets(username, data, client_type)
|
||||
elif command == 'scale_assets':
|
||||
await self.asset_handler.handle_scale_assets(username, data, client_type)
|
||||
elif command == 'get_asset_info':
|
||||
await self.asset_handler.handle_get_asset_info(username, data, client_type)
|
||||
|
||||
command = {
|
||||
"command": data.get("command"),
|
||||
"params": data.get("params"),
|
||||
"message_id": message_id
|
||||
}
|
||||
async def _route_to_template_handler(self, username: str, data: Dict[str, Any], client_type: str, command: str):
|
||||
"""Route template-related commands to the template handler"""
|
||||
if command == 'get_template_controls':
|
||||
await self.template_handler.handle_get_template_controls(username, data, client_type, command)
|
||||
|
||||
await session.blender_socket.send_json(command)
|
||||
await session.browser_socket.send_json({
|
||||
"status": "OK",
|
||||
"message": "Preview rendering started"
|
||||
})
|
||||
|
||||
async def _handle_generate_video(self, username: str, data: Dict[str, Any], client_type: str):
|
||||
"""Handle video generation requests"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
session = self.session_manager.get_session(username)
|
||||
if not session or not session.blender_socket:
|
||||
await self._send_error(username, "Blender client not connected")
|
||||
return
|
||||
|
||||
await session.blender_socket.send_json(data)
|
||||
await session.browser_socket.send_json({
|
||||
"status": "OK",
|
||||
"message": "Video generation started"
|
||||
})
|
||||
|
||||
async def _handle_start_broadcast(self, username: str, data: Dict[str, Any], client_type: str):
|
||||
"""Start/resume frame broadcasting from the last frame or the beginning"""
|
||||
session = self.session_manager.get_session(username)
|
||||
if not session:
|
||||
return
|
||||
|
||||
# Do not reset last_frame_index; resume from where it left off
|
||||
session.should_broadcast = True
|
||||
|
||||
# Create new task only if none exists or previous completed
|
||||
if not hasattr(session, 'broadcast_task') or session.broadcast_task.done():
|
||||
session.broadcast_task = asyncio.create_task(
|
||||
self._broadcast_frames(username)
|
||||
)
|
||||
|
||||
# Notify client
|
||||
if session.browser_socket:
|
||||
await session.browser_socket.send_json({
|
||||
"status": "OK",
|
||||
"message": "Frame broadcast started/resumed"
|
||||
})
|
||||
|
||||
async def _handle_stop_broadcast(self, username: str, data: Dict[str, Any], client_type: str):
|
||||
"""Stop frame broadcasting immediately"""
|
||||
session = self.session_manager.get_session(username)
|
||||
if not session:
|
||||
return
|
||||
|
||||
# Set flag to break broadcast loop
|
||||
session.should_broadcast = False
|
||||
|
||||
# Cancel task if running
|
||||
if hasattr(session, 'broadcast_task'):
|
||||
if not session.broadcast_task.done():
|
||||
session.broadcast_task.cancel()
|
||||
try:
|
||||
await session.broadcast_task # Handle cleanup
|
||||
except asyncio.CancelledError:
|
||||
self.logger.info(f"Broadcast stopped for {username}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping broadcast: {e}")
|
||||
|
||||
# Notify client
|
||||
if session.browser_socket:
|
||||
await session.browser_socket.send_json({
|
||||
"status": "OK",
|
||||
"message": "Frame broadcast stopped"
|
||||
})
|
||||
|
||||
async def _broadcast_frames(self, username: str):
|
||||
"""Broadcast frames once (stops after last frame)"""
|
||||
session = self.session_manager.get_session(username)
|
||||
if not session or not session.browser_socket:
|
||||
return
|
||||
|
||||
try:
|
||||
frames = sorted(self.preview_dir.glob("frame_*.png"))
|
||||
if not frames:
|
||||
return
|
||||
|
||||
# Start from the frame after the last frame that was sent
|
||||
start_frame_index = session.last_frame_index + \
|
||||
1 if session.last_frame_index is not None else 0
|
||||
|
||||
# Broadcast frames sequentially (no automatic looping)
|
||||
for frame_index in range(start_frame_index, len(frames)):
|
||||
frame = frames[frame_index]
|
||||
|
||||
if not session.should_broadcast: # Check pause/stop flag
|
||||
break
|
||||
|
||||
try:
|
||||
with open(frame, "rb") as img_file:
|
||||
img_str = base64.b64encode(img_file.read()).decode()
|
||||
await session.browser_socket.send_json({
|
||||
"type": "frame",
|
||||
"data": img_str,
|
||||
"frame_index": frame_index,
|
||||
})
|
||||
session.last_frame_index = frame_index # Track progress
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error sending frame: {e}")
|
||||
session.should_broadcast = False
|
||||
return
|
||||
|
||||
await asyncio.sleep(0.033) # ~30 FPS
|
||||
|
||||
# Notify client the broadcast finished (only if it completed fully)
|
||||
if session.last_frame_index == len(frames) - 1:
|
||||
await session.browser_socket.send_json({
|
||||
"type": "broadcast_complete"
|
||||
})
|
||||
# Reset last_frame_index only after the last frame has been sent
|
||||
session.last_frame_index = -1
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self.logger.info(f"Broadcast cancelled for {username}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Broadcast error: {e}")
|
||||
finally:
|
||||
session.should_broadcast = False # Ensure broadcast stops
|
||||
|
||||
async def _handle_get_template_controls(self, username: str, data: Dict[str, Any], client_type: str):
|
||||
"""Handle template controls request with proper message tracking"""
|
||||
try:
|
||||
# Generate and track message ID
|
||||
message_id = str(uuid.uuid4())
|
||||
print(f"Generated new message_id: {message_id}")
|
||||
self.session_manager.add_pending_request(username, message_id)
|
||||
|
||||
# Get session and validate connection
|
||||
session = self.session_manager.get_session(username)
|
||||
if not session or not session.blender_socket:
|
||||
await self._send_error(username, "Blender client not connected")
|
||||
return
|
||||
|
||||
# Prepare and send command to Blender
|
||||
command = {
|
||||
"command": "rescan_template",
|
||||
"message_id": message_id
|
||||
}
|
||||
await session.blender_socket.send_json(command)
|
||||
|
||||
# Log successful request
|
||||
self.logger.info(
|
||||
f"Sent template controls request to Blender for {username} (message_id: {message_id})")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error handling template controls request: {str(e)}")
|
||||
if 'message_id' in locals():
|
||||
# Clean up pending request on error
|
||||
self.session_manager.remove_pending_request(message_id)
|
||||
await self._send_error(username, f"Failed to process template controls request: {str(e)}")
|
||||
|
||||
async def _handle_template_controls_response(self, username: str, data: Dict[str, Any], client_type: str):
|
||||
"""Handle template controls response"""
|
||||
self.logger.debug(
|
||||
f"Received template controls response from {username}: {data}")
|
||||
|
||||
try:
|
||||
# Verify and extract the response data
|
||||
if not isinstance(data, dict):
|
||||
self.logger.error(f"Invalid data format: {type(data)}")
|
||||
return
|
||||
|
||||
# Extract data from response
|
||||
response_data = data.get("data", {})
|
||||
if not response_data:
|
||||
self.logger.error("No data in template controls response")
|
||||
self.logger.debug(f"Response data: {data}")
|
||||
return
|
||||
|
||||
# Log the structure of response_data for debugging
|
||||
self.logger.debug(f"Response data structure: {response_data}")
|
||||
|
||||
# Extract inner data from response_data
|
||||
inner_data = response_data.get("data", {})
|
||||
if not inner_data:
|
||||
self.logger.error(
|
||||
"No inner data in template controls response")
|
||||
self.logger.debug(f"Response data: {response_data}")
|
||||
return
|
||||
|
||||
# Log the structure of inner_data for debugging
|
||||
self.logger.debug(f"Inner data structure: {inner_data}")
|
||||
|
||||
# Get message_id from inner data
|
||||
message_id = inner_data.get("message_id")
|
||||
if not message_id:
|
||||
self.logger.error(
|
||||
"No message_id in template controls response data")
|
||||
self.logger.debug(f"Inner data: {inner_data}")
|
||||
return
|
||||
|
||||
# Extract controllables from the inner data object
|
||||
controllables = inner_data.get("controllables", {})
|
||||
print(
|
||||
f"Found message_id: {message_id}, controllables: {bool(controllables)}")
|
||||
|
||||
# Rest of the code remains unchanged...
|
||||
self.logger.debug(
|
||||
f"Processing response with message_id: {message_id}")
|
||||
request_username = self.session_manager.get_pending_request(
|
||||
message_id)
|
||||
if not request_username:
|
||||
self.logger.error(
|
||||
f"No pending request for message_id {message_id}")
|
||||
return
|
||||
|
||||
self.logger.debug(
|
||||
f"Processing template controls for {request_username}")
|
||||
|
||||
# Get the session for the requesting user
|
||||
session = self.session_manager.get_session(request_username)
|
||||
if not session:
|
||||
self.logger.error(f"No session found for {request_username}")
|
||||
return
|
||||
|
||||
if not session.browser_socket:
|
||||
self.logger.error(f"No browser socket for {request_username}")
|
||||
return
|
||||
|
||||
# Validate controllables data
|
||||
if not isinstance(controllables, dict):
|
||||
self.logger.error(
|
||||
f"Invalid controllables format: {type(controllables)}")
|
||||
controllables = {}
|
||||
|
||||
# Prepare and send the response
|
||||
response = {
|
||||
"command": "template_controls",
|
||||
"controllables": controllables,
|
||||
"status": "success",
|
||||
"message_id": message_id
|
||||
}
|
||||
|
||||
self.logger.debug(
|
||||
f"Sending template controls to {request_username}: {response}")
|
||||
await session.browser_socket.send_json(response)
|
||||
self.logger.info(
|
||||
f"Successfully sent template controls to {request_username}")
|
||||
|
||||
# Clean up the pending request after successful response
|
||||
self.session_manager.remove_pending_request(message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error in template controls response handling: {str(e)}")
|
||||
# Attempt to send error response if possible
|
||||
if 'session' in locals() and session and session.browser_socket:
|
||||
await session.browser_socket.send_json({
|
||||
"command": "template_controls",
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
})
|
||||
# Don't clean up pending request on error to allow for retries
|
||||
if 'message_id' in locals():
|
||||
self.logger.debug(
|
||||
f"Keeping pending request for retry. message_id: {message_id}")
|
||||
async def _route_to_preview_handler(self, username: str, data: Dict[str, Any], client_type: str, command: str):
|
||||
"""Route preview-related commands to the preview handler"""
|
||||
if command == 'start_preview_rendering':
|
||||
await self.preview_handler.handle_preview_rendering(username, data, client_type)
|
||||
elif command == 'generate_video':
|
||||
await self.preview_handler.handle_generate_video(username, data, client_type)
|
||||
elif command == 'start_broadcast':
|
||||
await self.preview_handler.handle_start_broadcast(username, data, client_type)
|
||||
elif command == 'stop_broadcast':
|
||||
await self.preview_handler.handle_stop_broadcast(username, data, client_type)
|
||||
|
||||
async def _handle_command_completion(self, username: str, data: Dict[str, Any]):
|
||||
"""Handle command completion"""
|
||||
@@ -405,98 +268,6 @@ class WebSocketHandler:
|
||||
"status": "success"
|
||||
})
|
||||
|
||||
async def _handle_asset_operation(self, username: str, data: Dict[str, Any], client_type: str):
|
||||
"""Handle asset operations (append, remove, swap, rotate, scale, info)"""
|
||||
if client_type != "browser":
|
||||
return
|
||||
|
||||
try:
|
||||
# Generate and track message ID
|
||||
message_id = str(uuid.uuid4())
|
||||
self.logger.info(
|
||||
f"Generated new message_id for asset operation: {message_id}")
|
||||
self.session_manager.add_pending_request(username, message_id)
|
||||
|
||||
# Get session and validate connection
|
||||
session = self.session_manager.get_session(username)
|
||||
if not session or not session.blender_socket:
|
||||
await self._send_error(username, "Blender client not connected")
|
||||
return
|
||||
|
||||
# Prepare command with original data plus message_id
|
||||
command = {**data, "message_id": message_id}
|
||||
|
||||
# Forward to Blender
|
||||
await session.blender_socket.send_json(command)
|
||||
|
||||
# Confirm receipt to browser
|
||||
await session.browser_socket.send_json({
|
||||
"status": "OK",
|
||||
"message": f"{data.get('command')} request sent to Blender",
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
self.logger.info(
|
||||
f"Asset operation {data.get('command')} forwarded to Blender for {username}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling asset operation: {str(e)}")
|
||||
if 'message_id' in locals():
|
||||
# Clean up pending request on error
|
||||
self.session_manager.remove_pending_request(message_id)
|
||||
await self._send_error(username, f"Failed to process asset operation: {str(e)}")
|
||||
|
||||
async def _handle_asset_operation_response(self, username: str, data: Dict[str, Any], client_type: str):
|
||||
"""Handle responses from asset operations"""
|
||||
if client_type != "blender":
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract message_id and response data
|
||||
message_id = data.get("message_id")
|
||||
if not message_id:
|
||||
self.logger.error("No message_id in asset operation response")
|
||||
return
|
||||
|
||||
# Find requesting user
|
||||
request_username = self.session_manager.get_pending_request(
|
||||
message_id)
|
||||
if not request_username:
|
||||
self.logger.error(
|
||||
f"No pending request for message_id {message_id}")
|
||||
return
|
||||
|
||||
# Get session
|
||||
session = self.session_manager.get_session(request_username)
|
||||
if not session or not session.browser_socket:
|
||||
self.logger.error(f"No valid session for {request_username}")
|
||||
return
|
||||
|
||||
# Forward response to browser
|
||||
await session.browser_socket.send_json(data)
|
||||
self.logger.info(
|
||||
f"Asset operation response forwarded to browser for {request_username}")
|
||||
|
||||
# Clean up
|
||||
self.session_manager.remove_pending_request(message_id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error handling asset operation response: {str(e)}")
|
||||
# Don't clean up pending request on error to allow for retries
|
||||
if 'message_id' in locals() and 'request_username' in locals():
|
||||
self.logger.debug(
|
||||
f"Keeping pending request for retry. message_id: {message_id}")
|
||||
# Try to send error to browser if possible
|
||||
session = self.session_manager.get_session(request_username)
|
||||
if session and session.browser_socket:
|
||||
await session.browser_socket.send_json({
|
||||
"command": data.get("command", "unknown_asset_operation"),
|
||||
"status": "error",
|
||||
"message": str(e),
|
||||
"message_id": message_id
|
||||
})
|
||||
|
||||
async def _send_error(self, username: str, message: str):
|
||||
"""Send error message to browser client"""
|
||||
session = self.session_manager.get_session(username)
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import uvicorn
|
||||
import json
|
||||
import websockets
|
||||
from sqlmodel import create_engine, SQLModel
|
||||
from fastapi import FastAPI, WebSocket, status, WebSocketDisconnect, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -154,31 +156,117 @@ session_manager = SessionManager()
|
||||
|
||||
@app.websocket("/ws/{username}/{client_type}")
|
||||
async def websocket_endpoint(websocket: WebSocket, username: str, client_type: str, blend_file: str = None):
|
||||
await websocket.accept()
|
||||
# Track if we've accepted the connection
|
||||
connection_accepted = False
|
||||
|
||||
try:
|
||||
await websocket.accept()
|
||||
connection_accepted = True
|
||||
|
||||
if client_type == "browser":
|
||||
session = await session_manager.create_browser_session(username, websocket, blend_file)
|
||||
await websocket.send_json({"status": "connected", "message": "Session created"})
|
||||
try:
|
||||
session = await session_manager.create_browser_session(username, websocket, blend_file)
|
||||
await websocket.send_json({"status": "connected", "message": "Session created"})
|
||||
except ValueError as ve:
|
||||
# Handle specific ValueError exceptions gracefully
|
||||
error_message = str(ve)
|
||||
logger.warning(f"Browser connection rejected: {error_message}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"status": "error",
|
||||
"message": error_message,
|
||||
"code": "connection_rejected"
|
||||
})
|
||||
except Exception as send_error:
|
||||
logger.warning(
|
||||
f"Could not send error response: {str(send_error)}")
|
||||
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception as close_error:
|
||||
logger.warning(
|
||||
f"Error closing websocket: {str(close_error)}")
|
||||
|
||||
return # Exit early without raising the exception further
|
||||
|
||||
elif client_type == "blender":
|
||||
await session_manager.register_blender(username, websocket)
|
||||
await websocket.send_json({"status": "connected", "message": "Blender registered"})
|
||||
try:
|
||||
await session_manager.register_blender(username, websocket)
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"command": "connection_confirmation",
|
||||
"status": "connected",
|
||||
"message": "Blender registered"
|
||||
})
|
||||
except Exception as send_error:
|
||||
logger.warning(
|
||||
f"Could not send confirmation to Blender: {str(send_error)}")
|
||||
# Even if we can't send confirmation, continue as the connection might still work
|
||||
except ValueError as ve:
|
||||
logger.warning(f"Blender registration failed: {str(ve)}")
|
||||
try:
|
||||
await websocket.send_json({"status": "error", "message": str(ve)})
|
||||
except Exception:
|
||||
pass # Socket might already be closed
|
||||
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass # Socket might already be closed
|
||||
|
||||
return # Exit early
|
||||
|
||||
websocket_handler = WebSocketHandler(session_manager, username)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
# Process message with the handler
|
||||
await websocket_handler.handle_message(username, data, client_type)
|
||||
try:
|
||||
data = await websocket.receive_json()
|
||||
# Process message with the handler
|
||||
await websocket_handler.handle_message(username, data, client_type)
|
||||
except json.JSONDecodeError as json_err:
|
||||
logger.warning(f"Invalid JSON received: {str(json_err)}")
|
||||
continue # Skip this message but keep connection open
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"WebSocket disconnected: {username}/{client_type}")
|
||||
await session_manager.handle_disconnect(username, client_type)
|
||||
|
||||
except ValueError as ve:
|
||||
# Handle any other ValueError exceptions
|
||||
logger.warning(
|
||||
f"WebSocket error for {username}/{client_type}: {str(ve)}")
|
||||
if connection_accepted:
|
||||
try:
|
||||
await websocket.send_json({"status": "error", "message": str(ve)})
|
||||
except Exception:
|
||||
pass # Socket might already be closed
|
||||
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass # Socket might already be closed
|
||||
|
||||
except websockets.exceptions.ConnectionClosedError as conn_err:
|
||||
# Handle connection closed errors gracefully
|
||||
logger.warning(
|
||||
f"Connection closed: {username}/{client_type}: {str(conn_err)}")
|
||||
if client_type == "blender":
|
||||
# Notify session manager of Blender disconnect
|
||||
await session_manager.handle_disconnect(username, client_type)
|
||||
|
||||
except Exception as e:
|
||||
await websocket.close()
|
||||
raise e
|
||||
logger.error(f"Unexpected WebSocket error: {str(e)}")
|
||||
if connection_accepted:
|
||||
try:
|
||||
await websocket.send_json({"status": "error", "message": "Internal server error"})
|
||||
except Exception:
|
||||
pass # Socket might already be closed
|
||||
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass # Socket might already be closed
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
|
33
docs/README.md
Normal file
33
docs/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Cr8 App Documentation
|
||||
|
||||
This documentation provides comprehensive information about the Cr8 App, its architecture, components, and usage.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Animations System](./animations/overview.md)
|
||||
- [Architecture](./animations/architecture.md)
|
||||
- Implementation Details
|
||||
- [Backend Implementation](./animations/implementation/backend.md)
|
||||
- [Frontend Implementation](./animations/implementation/frontend.md)
|
||||
- [WebSocket Communication](./animations/implementation/websocket.md)
|
||||
- [Usage Guide](./animations/usage.md)
|
||||
|
||||
## Project Overview
|
||||
|
||||
Cr8 is a creative platform that integrates with Blender to provide a web-based interface for manipulating 3D scenes. The application consists of several key components:
|
||||
|
||||
1. **Frontend**: A React-based web application that provides the user interface
|
||||
2. **cr8_engine**: A middleware service that handles API requests and WebSocket communication
|
||||
3. **blender_cr8tive_engine**: A Blender addon that interfaces with Blender to manipulate 3D scenes
|
||||
|
||||
## Key Features
|
||||
|
||||
- Real-time scene manipulation via WebSocket communication
|
||||
- Template-based scene creation and customization
|
||||
- Animation controls for camera, lighting, and product animations
|
||||
- Asset management and placement
|
||||
- Scene configuration and rendering
|
||||
|
||||
## Getting Started
|
||||
|
||||
Refer to the specific documentation sections for detailed information about each component and feature.
|
496
docs/animations/implementation/websocket.md
Normal file
496
docs/animations/implementation/websocket.md
Normal file
@@ -0,0 +1,496 @@
|
||||
# WebSocket Communication
|
||||
|
||||
This document details the WebSocket communication protocol used by the Animation System, covering message formats, command structure, and error handling.
|
||||
|
||||
## Overview
|
||||
|
||||
The Animation System uses WebSocket communication to enable real-time interaction between the frontend and the backend. This allows for immediate feedback when applying animations to the 3D scene.
|
||||
|
||||
The communication flow follows this pattern:
|
||||
|
||||
1. Frontend sends animation commands to cr8_engine via WebSocket
|
||||
2. cr8_engine forwards commands to blender_cr8tive_engine
|
||||
3. blender_cr8tive_engine executes the commands in Blender
|
||||
4. Results are sent back through the same path in reverse
|
||||
|
||||
## WebSocket Connection
|
||||
|
||||
### Connection Establishment
|
||||
|
||||
The WebSocket connection is established when the user loads the application. The connection URL includes the username and template information:
|
||||
|
||||
```typescript
|
||||
// frontend/hooks/useWebsocket.ts
|
||||
|
||||
const getWebSocketUrl = useCallback(() => {
|
||||
if (!userInfo?.username || !template) {
|
||||
return null;
|
||||
}
|
||||
return `${websocketUrl}/${userInfo.username}/browser?blend_file=${template}`;
|
||||
}, [userInfo?.username, template, websocketUrl]);
|
||||
```
|
||||
|
||||
### Connection Management
|
||||
|
||||
The WebSocket connection is managed by the `useWebSocket` hook, which handles connection establishment, reconnection, and message sending:
|
||||
|
||||
```typescript
|
||||
// frontend/hooks/useWebsocket.ts
|
||||
|
||||
export const useWebSocket = (onMessage?: (data: any) => void) => {
|
||||
const [status, setStatus] = useState<WebSocketStatus>("disconnected");
|
||||
const websocketRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
// ... other state and refs
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const url = getWebSocketUrl();
|
||||
if (!url) {
|
||||
toast.error("Missing connection details");
|
||||
return;
|
||||
}
|
||||
|
||||
// ... connection logic
|
||||
|
||||
try {
|
||||
setStatus("connecting");
|
||||
const ws = new WebSocket(url);
|
||||
websocketRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus("connected");
|
||||
// ... handle successful connection
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
// ... handle connection close
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
// ... handle connection error
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// ... handle incoming messages
|
||||
};
|
||||
} catch (error) {
|
||||
// ... handle connection failure
|
||||
}
|
||||
}, [getWebSocketUrl, sendMessage, attemptReconnect]);
|
||||
|
||||
// ... other methods
|
||||
|
||||
return {
|
||||
status,
|
||||
websocket: websocketRef.current,
|
||||
isConnected: status === "connected",
|
||||
reconnect,
|
||||
disconnect,
|
||||
sendMessage,
|
||||
requestTemplateControls,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Message Format
|
||||
|
||||
### Command Messages
|
||||
|
||||
Commands sent from the frontend to the backend follow a standard format:
|
||||
|
||||
```typescript
|
||||
interface WebSocketMessage {
|
||||
command: string; // The command to execute
|
||||
empty_name?: string; // The target empty (for animation commands)
|
||||
data?: any; // Command-specific data
|
||||
message_id?: string; // Unique ID for tracking the command
|
||||
}
|
||||
```
|
||||
|
||||
### Response Messages
|
||||
|
||||
Responses from the backend to the frontend also follow a standard format:
|
||||
|
||||
```typescript
|
||||
interface WebSocketResponse {
|
||||
command: string; // The command that was executed
|
||||
status: "success" | "error"; // The status of the command
|
||||
message?: string; // A message describing the result
|
||||
message_id?: string; // The ID of the original command
|
||||
data?: any; // Command-specific response data
|
||||
}
|
||||
```
|
||||
|
||||
## Animation Commands
|
||||
|
||||
### Camera Animation Command
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "load_camera_animation",
|
||||
"empty_name": "Camera",
|
||||
"data": {
|
||||
"animation_id": "cam_orbit_01",
|
||||
"parameters": {
|
||||
"duration": 5.0,
|
||||
"easing": "ease-in-out"
|
||||
}
|
||||
},
|
||||
"message_id": "anim_1234567890"
|
||||
}
|
||||
```
|
||||
|
||||
### Light Animation Command
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "load_light_animation",
|
||||
"empty_name": "Light",
|
||||
"data": {
|
||||
"animation_id": "light_pulse_01",
|
||||
"parameters": {
|
||||
"duration": 3.0,
|
||||
"intensity": 1.5
|
||||
}
|
||||
},
|
||||
"message_id": "anim_1234567891"
|
||||
}
|
||||
```
|
||||
|
||||
### Product Animation Command
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "load_product_animation",
|
||||
"empty_name": "Product",
|
||||
"data": {
|
||||
"animation_id": "product_rotate_01",
|
||||
"parameters": {
|
||||
"duration": 4.0,
|
||||
"rotation_axis": "z"
|
||||
}
|
||||
},
|
||||
"message_id": "anim_1234567892"
|
||||
}
|
||||
```
|
||||
|
||||
## Animation Responses
|
||||
|
||||
### Success Response
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "animation_result",
|
||||
"status": "success",
|
||||
"message": "Animation applied successfully",
|
||||
"message_id": "anim_1234567890",
|
||||
"data": {
|
||||
"animation_id": "cam_orbit_01",
|
||||
"target": "Camera",
|
||||
"duration": 5.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "animation_result",
|
||||
"status": "error",
|
||||
"message": "Error applying animation: Target empty not found",
|
||||
"message_id": "anim_1234567890"
|
||||
}
|
||||
```
|
||||
|
||||
## Message Handling
|
||||
|
||||
### Frontend Message Handling
|
||||
|
||||
The frontend handles WebSocket messages using the `processWebSocketMessage` function:
|
||||
|
||||
```typescript
|
||||
// frontend/lib/handlers/websocketMessageHandler.ts
|
||||
|
||||
export function processWebSocketMessage(data: any, handlers: MessageHandlers) {
|
||||
const { command } = data;
|
||||
|
||||
switch (command) {
|
||||
case "animation_result":
|
||||
if (handlers.onAnimationResponse) {
|
||||
handlers.onAnimationResponse(data);
|
||||
}
|
||||
break;
|
||||
case "template_controls_result":
|
||||
if (handlers.onTemplateControls) {
|
||||
handlers.onTemplateControls(data.data);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (handlers.onCustomMessage) {
|
||||
handlers.onCustomMessage(data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### cr8_engine Message Handling
|
||||
|
||||
The cr8_engine handles WebSocket messages using the `WebSocketHandler` class:
|
||||
|
||||
```python
|
||||
# backend/cr8_engine/app/realtime_engine/websockets/websocket_handler.py
|
||||
|
||||
class WebSocketHandler:
|
||||
def __init__(self):
|
||||
self.handler_registry = HandlerRegistry()
|
||||
self.register_handlers()
|
||||
|
||||
def register_handlers(self):
|
||||
# Register animation handlers
|
||||
self.handler_registry.register_handler("load_camera_animation", AnimationHandler())
|
||||
self.handler_registry.register_handler("load_light_animation", AnimationHandler())
|
||||
self.handler_registry.register_handler("load_product_animation", AnimationHandler())
|
||||
# ... other handlers
|
||||
|
||||
async def handle_message(self, message: dict, websocket: WebSocket):
|
||||
command = message.get("command")
|
||||
if not command:
|
||||
return {"status": "error", "message": "No command specified"}
|
||||
|
||||
handler = self.handler_registry.get_handler(command)
|
||||
if not handler:
|
||||
return {"status": "error", "message": f"No handler for command: {command}"}
|
||||
|
||||
return await handler.handle(message, websocket)
|
||||
```
|
||||
|
||||
### blender_cr8tive_engine Message Handling
|
||||
|
||||
The blender_cr8tive_engine handles WebSocket messages using a similar approach:
|
||||
|
||||
```python
|
||||
# backend/blender_cr8tive_engine/ws/websocket_handler.py
|
||||
|
||||
class WebSocketHandler:
|
||||
def __init__(self):
|
||||
self.handler_registry = HandlerRegistry()
|
||||
self.register_handlers()
|
||||
|
||||
def register_handlers(self):
|
||||
# Register animation handlers
|
||||
self.handler_registry.register_handler("load_camera_animation", AnimationHandler())
|
||||
self.handler_registry.register_handler("load_light_animation", AnimationHandler())
|
||||
self.handler_registry.register_handler("load_product_animation", AnimationHandler())
|
||||
# ... other handlers
|
||||
|
||||
async def handle_message(self, message: dict):
|
||||
command = message.get("command")
|
||||
if not command:
|
||||
return {"status": "error", "message": "No command specified"}
|
||||
|
||||
handler = self.handler_registry.get_handler(command)
|
||||
if not handler:
|
||||
return {"status": "error", "message": f"No handler for command: {command}"}
|
||||
|
||||
return await handler.handle(message)
|
||||
```
|
||||
|
||||
## Message Routing
|
||||
|
||||
### Frontend to cr8_engine
|
||||
|
||||
The frontend sends messages to cr8_engine using the `sendMessage` function from the WebSocket context:
|
||||
|
||||
```typescript
|
||||
// frontend/hooks/useAnimations.ts
|
||||
|
||||
const applyAnimation = useCallback(
|
||||
(animation: Animation, emptyName: string) => {
|
||||
try {
|
||||
// Create a unique message ID for tracking
|
||||
const messageId = `anim_${Date.now()}_${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 9)}`;
|
||||
|
||||
// Convert template_type to command type
|
||||
const animationType =
|
||||
animation.template_type === "product_animation"
|
||||
? "product"
|
||||
: animation.template_type;
|
||||
|
||||
// Determine the command based on animation type
|
||||
const command = `load_${animationType}_animation`;
|
||||
|
||||
// Send the animation command via WebSocket
|
||||
sendMessage({
|
||||
command,
|
||||
empty_name: emptyName,
|
||||
data: animation.templateData,
|
||||
message: messageId,
|
||||
});
|
||||
|
||||
toast.success(`Applying ${animationType} animation: ${animation.name}`);
|
||||
} catch (error) {
|
||||
console.error("Error applying animation:", error);
|
||||
toast.error("Failed to apply animation");
|
||||
}
|
||||
},
|
||||
[sendMessage]
|
||||
);
|
||||
```
|
||||
|
||||
### cr8_engine to blender_cr8tive_engine
|
||||
|
||||
The cr8_engine forwards messages to blender_cr8tive_engine using the `forward_to_blender` method:
|
||||
|
||||
```python
|
||||
# backend/cr8_engine/app/realtime_engine/websockets/handlers/animation_handler.py
|
||||
|
||||
class AnimationHandler(BaseHandler):
|
||||
async def handle(self, message: dict, websocket: WebSocket):
|
||||
# ... validation logic
|
||||
|
||||
# Forward the command to the Blender engine
|
||||
response = await self.forward_to_blender(message)
|
||||
|
||||
return response
|
||||
|
||||
async def forward_to_blender(self, message: dict):
|
||||
# Implementation of forwarding to Blender
|
||||
# This would typically involve sending the message to the Blender WebSocket server
|
||||
# and waiting for a response
|
||||
pass
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Frontend Error Handling
|
||||
|
||||
The frontend handles errors by catching exceptions and displaying toast notifications:
|
||||
|
||||
```typescript
|
||||
// frontend/hooks/useAnimations.ts
|
||||
|
||||
try {
|
||||
// ... animation logic
|
||||
} catch (error) {
|
||||
console.error("Error applying animation:", error);
|
||||
toast.error("Failed to apply animation");
|
||||
}
|
||||
```
|
||||
|
||||
### Backend Error Handling
|
||||
|
||||
The backend handles errors by catching exceptions and returning error responses:
|
||||
|
||||
```python
|
||||
# backend/blender_cr8tive_engine/ws/handlers/animation.py
|
||||
|
||||
try:
|
||||
# Execute the animation in Blender
|
||||
if command == "load_camera_animation":
|
||||
result = self.apply_camera_animation(empty_name, data)
|
||||
elif command == "load_light_animation":
|
||||
result = self.apply_light_animation(empty_name, data)
|
||||
elif command == "load_product_animation":
|
||||
result = self.apply_product_animation(empty_name, data)
|
||||
else:
|
||||
return {
|
||||
"command": "animation_result",
|
||||
"status": "error",
|
||||
"message": f"Unknown animation command: {command}",
|
||||
"message_id": message_id
|
||||
}
|
||||
|
||||
return {
|
||||
"command": "animation_result",
|
||||
"status": "success",
|
||||
"message": "Animation applied successfully",
|
||||
"message_id": message_id,
|
||||
"data": result
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"command": "animation_result",
|
||||
"status": "error",
|
||||
"message": f"Error applying animation: {str(e)}",
|
||||
"message_id": message_id
|
||||
}
|
||||
```
|
||||
|
||||
## Connection Status Handling
|
||||
|
||||
The frontend displays connection status information to the user:
|
||||
|
||||
```tsx
|
||||
// frontend/components/animations/AnimationPanel.tsx
|
||||
|
||||
{
|
||||
!isConnected ? (
|
||||
<div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-md text-sm">
|
||||
WebSocket connection is {status}. Animations cannot be applied until
|
||||
connected.
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
```
|
||||
|
||||
## Reconnection Strategy
|
||||
|
||||
The WebSocket connection includes a reconnection strategy with exponential backoff:
|
||||
|
||||
```typescript
|
||||
// frontend/hooks/useWebsocket.ts
|
||||
|
||||
const calculateReconnectDelay = useCallback(() => {
|
||||
return Math.min(
|
||||
BASE_DELAY * Math.pow(2, reconnectAttemptsRef.current),
|
||||
MAX_RECONNECT_DELAY
|
||||
);
|
||||
}, []);
|
||||
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (isManuallyDisconnected.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
||||
setStatus("failed");
|
||||
toast.error(
|
||||
"Maximum reconnection attempts reached. Please refresh the page to try again."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const delay = calculateReconnectDelay();
|
||||
toast.info(`Attempting to reconnect in ${delay / 1000} seconds...`);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
connect();
|
||||
}, delay);
|
||||
|
||||
return true;
|
||||
}, [calculateReconnectDelay]);
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
The WebSocket communication includes several security measures:
|
||||
|
||||
1. **Authentication**: WebSocket connections are authenticated using JWT tokens
|
||||
2. **Validation**: Commands are validated before execution
|
||||
3. **Error Handling**: Error messages are sanitized to prevent information leakage
|
||||
4. **Rate Limiting**: Excessive message sending is rate-limited
|
||||
|
||||
## Conclusion
|
||||
|
||||
The WebSocket communication protocol provides a robust foundation for real-time interaction between the frontend and the backend. It enables the Animation System to provide immediate feedback when applying animations to the 3D scene.
|
75
docs/animations/overview.md
Normal file
75
docs/animations/overview.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Animation System Overview
|
||||
|
||||
## Introduction
|
||||
|
||||
The Animation System in Cr8 provides a way for users to apply pre-defined animations to various elements in a 3D scene. This system has been designed to replace the previous approach where animations were loaded from the templateSystem's panels. Instead, animations are now loaded directly from the frontend, allowing users to select animations from a database and apply them to the scene via WebSocket commands.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Multiple Animation Types**: Support for three primary animation types:
|
||||
|
||||
- **Camera Animations**: Control camera movement, rotation, and focus
|
||||
- **Light Animations**: Animate light properties such as intensity, color, and position
|
||||
- **Product Animations**: Animate 3D objects/products in the scene
|
||||
|
||||
- **User-Friendly Interface**: Intuitive UI components for browsing and selecting animations
|
||||
|
||||
- Tabbed interface for organizing animations by type
|
||||
- Dropdown selectors for quick animation selection
|
||||
- Apply buttons for immediate animation application
|
||||
|
||||
- **Real-time Feedback**: Visual feedback on animation application status
|
||||
|
||||
- Connection status indicators
|
||||
- Success/error notifications
|
||||
- Loading states during animation fetching
|
||||
|
||||
- **WebSocket Communication**: Efficient communication between frontend and Blender
|
||||
- Commands sent from frontend to cr8_engine
|
||||
- cr8_engine forwards commands to blender_cr8tive_engine
|
||||
- Blender executes animations in the 3D scene
|
||||
|
||||
## System Components
|
||||
|
||||
The Animation System consists of several interconnected components:
|
||||
|
||||
1. **Frontend Components**:
|
||||
|
||||
- `AnimationControls`: Tabbed interface for browsing animations by type
|
||||
- `AnimationPanel`: Container component with connection status
|
||||
- `AnimationSelector`: Dropdown selector for individual animation types
|
||||
- Integration with `SceneControls` for a unified UI experience
|
||||
|
||||
2. **Backend Services**:
|
||||
|
||||
- **cr8_engine**: Middleware that handles WebSocket communication
|
||||
- **blender_cr8tive_engine**: Blender addon that executes animations
|
||||
|
||||
3. **Communication Protocol**:
|
||||
- WebSocket messages for real-time communication
|
||||
- Standardized command and response formats
|
||||
- Error handling and status reporting
|
||||
|
||||
## Benefits Over Previous Implementation
|
||||
|
||||
The new Animation System offers several advantages over the previous templateSystem-based approach:
|
||||
|
||||
1. **Improved User Experience**: Animations are now accessible directly from the main UI, eliminating the need to navigate to separate template panels.
|
||||
|
||||
2. **Centralized Control**: All animation types (camera, light, product) are managed through a consistent interface.
|
||||
|
||||
3. **Real-time Feedback**: Users receive immediate feedback on animation application status.
|
||||
|
||||
4. **Scalability**: The system is designed to easily accommodate new animation types and features.
|
||||
|
||||
5. **Maintainability**: Clear separation of concerns between frontend, middleware, and Blender execution.
|
||||
|
||||
## Next Steps
|
||||
|
||||
For more detailed information about the Animation System, refer to the following documentation:
|
||||
|
||||
- [Architecture](./architecture.md): Detailed system architecture and data flow
|
||||
- [Backend Implementation](./implementation/backend.md): Details of the backend implementation
|
||||
- [Frontend Implementation](./implementation/frontend.md): Details of the frontend components
|
||||
- [WebSocket Communication](./implementation/websocket.md): Details of the WebSocket protocol
|
||||
- [Usage Guide](./usage.md): How to use the Animation System
|
218
docs/animations/usage.md
Normal file
218
docs/animations/usage.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Animation System Usage Guide
|
||||
|
||||
This document provides a comprehensive guide on how to use the Animation System in the Cr8 application. It covers how to select and apply animations, as well as how to create new animations.
|
||||
|
||||
## Accessing Animation Controls
|
||||
|
||||
The Animation System is integrated into the Scene Controls panel in the Cr8 application. To access the animation controls:
|
||||
|
||||
1. Open a project in the Cr8 application
|
||||
2. Locate the Scene Controls panel on the left side of the screen
|
||||
3. Click on the "Animations" section to expand it
|
||||
|
||||
## Selecting and Applying Animations
|
||||
|
||||
### Using the Animation Selectors
|
||||
|
||||
The Animation System provides three animation selectors, one for each animation type:
|
||||
|
||||
1. **Camera Animation Selector**: For selecting and applying camera animations
|
||||
2. **Light Animation Selector**: For selecting and applying light animations
|
||||
3. **Product Animation Selector**: For selecting and applying product animations
|
||||
|
||||
To select and apply an animation:
|
||||
|
||||
1. Choose the appropriate animation selector based on the type of animation you want to apply
|
||||
2. Click on the dropdown to see available animations
|
||||
3. Select an animation from the list
|
||||
4. Click the "Apply" button to apply the animation to the scene
|
||||
|
||||
### Using the Animation Panel
|
||||
|
||||
For a more detailed view of available animations, you can use the Animation Panel:
|
||||
|
||||
1. Click on the "Animations" section in the Scene Controls panel
|
||||
2. The Animation Panel will display tabs for Camera, Light, and Product animations
|
||||
3. Select a tab to view animations of that type
|
||||
4. Browse through the available animations
|
||||
5. Click the "Apply" button on an animation card to apply it to the scene
|
||||
|
||||
## Animation Types
|
||||
|
||||
### Camera Animations
|
||||
|
||||
Camera animations control the movement, rotation, and focus of the camera in the scene. Examples include:
|
||||
|
||||
- **Orbit**: The camera orbits around a focal point
|
||||
- **Dolly**: The camera moves forward or backward
|
||||
- **Pan**: The camera moves horizontally
|
||||
- **Tilt**: The camera rotates up or down
|
||||
- **Zoom**: The camera's field of view changes
|
||||
|
||||
### Light Animations
|
||||
|
||||
Light animations control the properties of lights in the scene. Examples include:
|
||||
|
||||
- **Pulse**: The light intensity pulses up and down
|
||||
- **Color Shift**: The light color changes over time
|
||||
- **Flicker**: The light intensity varies randomly
|
||||
- **Rotate**: The light rotates around a point
|
||||
- **Fade**: The light fades in or out
|
||||
|
||||
### Product Animations
|
||||
|
||||
Product animations control the movement and properties of 3D objects in the scene. Examples include:
|
||||
|
||||
- **Rotate**: The object rotates around an axis
|
||||
- **Scale**: The object scales up or down
|
||||
- **Move**: The object moves along a path
|
||||
- **Bounce**: The object bounces up and down
|
||||
- **Shake**: The object shakes or vibrates
|
||||
|
||||
## Connection Status
|
||||
|
||||
The Animation System requires a WebSocket connection to the backend to apply animations. The connection status is displayed in the Animation Panel:
|
||||
|
||||
- **Connected**: Animations can be applied
|
||||
- **Connecting**: The system is attempting to establish a connection
|
||||
- **Disconnected**: The connection has been lost
|
||||
- **Failed**: The connection could not be established
|
||||
|
||||
If the connection is not established, you will see a warning message in the Animation Panel. You will need to wait for the connection to be established before you can apply animations.
|
||||
|
||||
## Creating New Animations
|
||||
|
||||
### Prerequisites
|
||||
|
||||
To create new animations, you need:
|
||||
|
||||
1. Access to the Blender file for the template
|
||||
2. Basic knowledge of Blender animation tools
|
||||
3. Understanding of the animation data format
|
||||
|
||||
### Creating a Camera Animation
|
||||
|
||||
To create a new camera animation:
|
||||
|
||||
1. Open the template in Blender
|
||||
2. Select the camera object
|
||||
3. Create keyframes for the camera's location, rotation, and other properties
|
||||
4. Export the animation data using the template export tool
|
||||
5. Import the animation data into the Cr8 database
|
||||
|
||||
### Creating a Light Animation
|
||||
|
||||
To create a new light animation:
|
||||
|
||||
1. Open the template in Blender
|
||||
2. Select the light object
|
||||
3. Create keyframes for the light's intensity, color, and other properties
|
||||
4. Export the animation data using the template export tool
|
||||
5. Import the animation data into the Cr8 database
|
||||
|
||||
### Creating a Product Animation
|
||||
|
||||
To create a new product animation:
|
||||
|
||||
1. Open the template in Blender
|
||||
2. Select the product object
|
||||
3. Create keyframes for the object's location, rotation, scale, and other properties
|
||||
4. Export the animation data using the template export tool
|
||||
5. Import the animation data into the Cr8 database
|
||||
|
||||
## Animation Data Format
|
||||
|
||||
Animations are stored in a JSON format with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "unique_animation_id",
|
||||
"name": "Animation Name",
|
||||
"template_type": "camera|light|product_animation",
|
||||
"templateData": {
|
||||
"keyframes": [
|
||||
{
|
||||
"frame": 0,
|
||||
"properties": {
|
||||
"location": [0, 0, 0],
|
||||
"rotation": [0, 0, 0],
|
||||
"scale": [1, 1, 1]
|
||||
}
|
||||
},
|
||||
{
|
||||
"frame": 10,
|
||||
"properties": {
|
||||
"location": [1, 0, 0],
|
||||
"rotation": [0, 0, 0],
|
||||
"scale": [1, 1, 1]
|
||||
}
|
||||
}
|
||||
],
|
||||
"duration": 5.0,
|
||||
"easing": "ease-in-out"
|
||||
},
|
||||
"is_public": true,
|
||||
"created_at": "2025-03-04T12:00:00Z",
|
||||
"updated_at": "2025-03-04T12:00:00Z",
|
||||
"creator_id": "user_id"
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Animation Not Applying
|
||||
|
||||
If an animation is not applying to the scene, check the following:
|
||||
|
||||
1. **WebSocket Connection**: Ensure that the WebSocket connection is established
|
||||
2. **Target Empty**: Make sure the target empty exists in the scene
|
||||
3. **Animation Data**: Verify that the animation data is valid
|
||||
4. **Console Errors**: Check the browser console for any error messages
|
||||
|
||||
### Animation Looks Incorrect
|
||||
|
||||
If an animation is applying but looks incorrect, check the following:
|
||||
|
||||
1. **Animation Data**: Verify that the animation data is correct
|
||||
2. **Target Empty**: Make sure the animation is being applied to the correct target
|
||||
3. **Scene Setup**: Ensure that the scene is set up correctly for the animation
|
||||
|
||||
### WebSocket Connection Issues
|
||||
|
||||
If you are experiencing WebSocket connection issues, try the following:
|
||||
|
||||
1. **Refresh the Page**: Refresh the page to attempt to re-establish the connection
|
||||
2. **Check Server Status**: Ensure that the backend server is running
|
||||
3. **Network Issues**: Check for any network issues that might be preventing the connection
|
||||
4. **Firewall Settings**: Ensure that your firewall is not blocking WebSocket connections
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Animation Selection
|
||||
|
||||
- Choose animations that complement the scene and product
|
||||
- Avoid animations that distract from the main focus of the scene
|
||||
- Use subtle animations for a professional look
|
||||
|
||||
### Animation Timing
|
||||
|
||||
- Keep animations short and to the point
|
||||
- Use appropriate timing for the type of animation
|
||||
- Consider the overall flow of the scene when selecting animation timing
|
||||
|
||||
### Animation Combinations
|
||||
|
||||
- Combine animations thoughtfully to create a cohesive scene
|
||||
- Avoid conflicting animations that might cancel each other out
|
||||
- Consider how different animations will interact with each other
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Animation System provides a powerful way to add dynamic elements to your 3D scenes. By following this guide, you should be able to effectively select, apply, and create animations for your projects.
|
||||
|
||||
For more detailed information about the Animation System, refer to the following documentation:
|
||||
|
||||
- [Architecture](./architecture.md): Detailed system architecture and data flow
|
||||
- [Backend Implementation](./implementation/backend.md): Details of the backend implementation
|
||||
- [Frontend Implementation](./implementation/frontend.md): Details of the frontend components
|
||||
- [WebSocket Communication](./implementation/websocket.md): Details of the WebSocket protocol
|
@@ -27,7 +27,7 @@ function Home() {
|
||||
<main className="container mx-auto px-4 pt-28 pb-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-48 mx-auto grid-cols-2 bg-white/10 p-1">
|
||||
<TabsList className="grid w-48 mx-auto grid-cols-2 bg-cr8-charcoal/10 border-white/10 border rounded-lg p-1">
|
||||
<TabsTrigger
|
||||
value="create"
|
||||
className="data-[state=active]:bg-[#0077B6] data-[state=active]:text-white"
|
||||
|
@@ -12,6 +12,8 @@ import { usePreviewRenderer } from "@/hooks/usePreviewRenderer";
|
||||
import { useWebSocketContext } from "@/contexts/WebSocketContext";
|
||||
import { useSceneConfigStore } from "@/store/sceneConfiguratorStore";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { useAnimationStore } from "@/store/animationStore";
|
||||
import { useAssetPlacerStore } from "@/store/assetPlacerStore";
|
||||
import { toast } from "sonner";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useServerHealth } from "@/hooks/useServerHealth";
|
||||
@@ -21,7 +23,6 @@ export const Route = createFileRoute("/project/$projectId")({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const [blender_connected, setBlenderConnected] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const { serverStatus } = useServerHealth();
|
||||
const navigate = useNavigate();
|
||||
@@ -50,8 +51,10 @@ function RouteComponent() {
|
||||
return () => {
|
||||
clearProject();
|
||||
clearControls();
|
||||
useAssetPlacerStore.getState().clearPlacedAssets();
|
||||
useAnimationStore.getState().clearSelections();
|
||||
};
|
||||
}, [clearProject]);
|
||||
}, [clearProject, clearControls]);
|
||||
|
||||
const updateCanvas = useCallback((imageData: string) => {
|
||||
if (!canvasRef.current) {
|
||||
@@ -75,7 +78,7 @@ function RouteComponent() {
|
||||
img.src = `data:image/png;base64,${imageData}`;
|
||||
}, []);
|
||||
|
||||
const { websocket, isConnected, requestTemplateControls } =
|
||||
const { websocket, isFullyConnected, requestTemplateControls } =
|
||||
useWebSocketContext();
|
||||
|
||||
const {
|
||||
@@ -92,17 +95,8 @@ function RouteComponent() {
|
||||
try {
|
||||
if (data.type === "frame") {
|
||||
updateCanvas(data.data);
|
||||
} else if (data.command === "template_controls") {
|
||||
const setTemplateControls =
|
||||
useTemplateControlsStore.getState().setControls;
|
||||
setTemplateControls(data.controllables);
|
||||
toast.success("Template controls loaded");
|
||||
} else if (
|
||||
data.type === "system" &&
|
||||
data.status === "blender_connected"
|
||||
) {
|
||||
setBlenderConnected(true);
|
||||
}
|
||||
// Template controls are now handled by the WebSocketContext handler
|
||||
// Handle asset operation responses
|
||||
else if (data.command === "append_asset_result") {
|
||||
if (data.status === "success") {
|
||||
@@ -147,12 +141,23 @@ function RouteComponent() {
|
||||
),
|
||||
});
|
||||
|
||||
// Request template controls when connected
|
||||
// Track if animations have been loaded
|
||||
const animationsLoaded = useRef(false);
|
||||
|
||||
// Request template controls and animations when connected
|
||||
useEffect(() => {
|
||||
if (blender_connected || isConnected) {
|
||||
if (isFullyConnected) {
|
||||
// Request template controls
|
||||
requestTemplateControls();
|
||||
|
||||
// Load animations if not already loaded
|
||||
if (!animationsLoaded.current) {
|
||||
animationsLoaded.current = true;
|
||||
const fetchAnimations = useAnimationStore.getState().fetchAllAnimations;
|
||||
fetchAnimations();
|
||||
}
|
||||
}
|
||||
}, [isConnected, requestTemplateControls, blender_connected]);
|
||||
}, [isFullyConnected, requestTemplateControls]);
|
||||
|
||||
// Set up canvas dimensions
|
||||
useEffect(() => {
|
||||
|
@@ -68,7 +68,7 @@ const Navbar = () => {
|
||||
className={`fixed top-4 left-4 right-4 z-50 transition-all transform -translate-y-1/2 duration-300 ${isVisible ? "translate-y-0" : "-translate-y-full"} `}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-lg border border-white/20 shadow-lg">
|
||||
<div className="bg-cr8-charcoal/10 backdrop-blur-md rounded-lg border border-white/10 shadow-lg">
|
||||
<div className="flex justify-between items-center px-6 py-3">
|
||||
<div className="flex items-center">
|
||||
<Link to="/" className="flex items-center">
|
||||
|
118
frontend/components/animations/AnimationCard.tsx
Normal file
118
frontend/components/animations/AnimationCard.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from "react";
|
||||
import { Animation } from "@/lib/types/animations";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAnimations } from "@/hooks/useAnimations";
|
||||
import { toast } from "sonner";
|
||||
import { useAnimationStore } from "@/store/animationStore";
|
||||
|
||||
interface AnimationCardProps {
|
||||
animation: Animation;
|
||||
isSelected: boolean;
|
||||
targetEmpty?: string | null;
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function AnimationCard({
|
||||
animation,
|
||||
isSelected,
|
||||
targetEmpty,
|
||||
onSelect,
|
||||
}: AnimationCardProps) {
|
||||
const { applyAnimation } = useAnimations();
|
||||
const { selectAnimation } = useAnimationStore();
|
||||
|
||||
const handleApply = () => {
|
||||
if (!targetEmpty) {
|
||||
toast.error("No target selected");
|
||||
return;
|
||||
}
|
||||
|
||||
applyAnimation(animation, targetEmpty);
|
||||
// Also update the selected animation in the store
|
||||
// Convert template_type to the format expected by selectAnimation
|
||||
const animationType =
|
||||
animation.template_type === "product_animation"
|
||||
? "product"
|
||||
: (animation.template_type as "camera" | "light");
|
||||
|
||||
selectAnimation(animationType, animation.id);
|
||||
toast.success(`Applied ${animation.name}`);
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (onSelect) {
|
||||
onSelect(animation.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={`relative rounded-md overflow-hidden cursor-pointer transition-all
|
||||
${isSelected ? "ring-2 ring-primary" : "hover:ring-1 ring-white/30"}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* Thumbnail with fallback */}
|
||||
<div className="aspect-video bg-black/20 relative">
|
||||
{animation.thumbnail ? (
|
||||
<img
|
||||
src={animation.thumbnail}
|
||||
alt={animation.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-black/40">
|
||||
<span className="text-xs text-white/60">No preview</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Animation name */}
|
||||
<div className="p-2 bg-black/60">
|
||||
<h4 className="text-sm font-medium truncate">{animation.name}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-72 p-0">
|
||||
<div className="space-y-2">
|
||||
{/* Larger thumbnail */}
|
||||
<div className="aspect-video bg-black/20 relative">
|
||||
{animation.thumbnail ? (
|
||||
<img
|
||||
src={animation.thumbnail}
|
||||
alt={animation.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-black/40">
|
||||
<span className="text-sm text-white/60">No preview</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
<h3 className="font-medium">{animation.name}</h3>
|
||||
<p className="text-sm text-white/70">
|
||||
{animation.template_type.replace("_", " ")}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleApply}
|
||||
disabled={!targetEmpty}
|
||||
>
|
||||
Apply Animation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
81
frontend/components/animations/AnimationDialog.tsx
Normal file
81
frontend/components/animations/AnimationDialog.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Animation } from "@/lib/types/animations";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface AnimationDialogProps {
|
||||
animations: Animation[];
|
||||
animationType: "camera" | "light" | "product";
|
||||
targetEmpty: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AnimationDialog({
|
||||
animations,
|
||||
animationType,
|
||||
targetEmpty,
|
||||
onClose,
|
||||
}: AnimationDialogProps) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 8; // More items per page in the dialog
|
||||
|
||||
const totalPages = Math.ceil(animations.length / itemsPerPage);
|
||||
|
||||
const displayedAnimations = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
return animations.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [animations, currentPage]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-medium">
|
||||
{animationType.charAt(0).toUpperCase() + animationType.slice(1)}{" "}
|
||||
Animations
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Animation grid - larger grid (4 columns) */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{displayedAnimations.map((animation) => (
|
||||
<AnimationCard
|
||||
key={animation.id}
|
||||
animation={animation}
|
||||
targetEmpty={targetEmpty}
|
||||
isSelected={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-sm">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="default" onClick={onClose} className="w-full">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
137
frontend/components/animations/AnimationSection.tsx
Normal file
137
frontend/components/animations/AnimationSection.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAnimationStore } from "@/store/animationStore";
|
||||
import { useAnimations } from "@/hooks/useAnimations";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface AnimationSectionProps {
|
||||
type: "camera" | "light" | "product";
|
||||
label: string;
|
||||
empties: string[];
|
||||
}
|
||||
|
||||
export function AnimationSection({
|
||||
type,
|
||||
label,
|
||||
empties,
|
||||
}: AnimationSectionProps) {
|
||||
const { animations, selectedAnimations, selectAnimation } =
|
||||
useAnimationStore();
|
||||
const [selectedEmpty, setSelectedEmpty] = useState<string | null>(null);
|
||||
const { applyAnimation } = useAnimations();
|
||||
|
||||
// Use a ref to track if we've already done the initial selection
|
||||
const initialSelectionDone = useRef(false);
|
||||
|
||||
// Get all animations for this type
|
||||
const typeAnimations = animations[type] || [];
|
||||
|
||||
// Get the currently selected animation ID for this type
|
||||
const selectedAnimationId = selectedAnimations[type];
|
||||
|
||||
// Find the full animation object
|
||||
const selectedAnimation = typeAnimations.find(
|
||||
(a) => a.id === selectedAnimationId
|
||||
);
|
||||
|
||||
// Track if we can apply (need both animation and empty)
|
||||
const canApply = !!selectedAnimationId && !!selectedEmpty;
|
||||
|
||||
// Auto-select first empty ONLY on initial render or when empties change
|
||||
// and we haven't selected anything yet
|
||||
useEffect(() => {
|
||||
if (empties.length > 0 && !selectedEmpty && !initialSelectionDone.current) {
|
||||
initialSelectionDone.current = true;
|
||||
setSelectedEmpty(empties[0]);
|
||||
}
|
||||
}, [empties]); // Careful: don't include selectedEmpty in dependencies
|
||||
|
||||
// Reset selection when no empties are available
|
||||
useEffect(() => {
|
||||
if (empties.length === 0 && selectedEmpty) {
|
||||
setSelectedEmpty(null);
|
||||
initialSelectionDone.current = false; // Reset tracking ref
|
||||
}
|
||||
}, [empties, selectedEmpty]);
|
||||
|
||||
const handleApplyAnimation = () => {
|
||||
if (!selectedAnimation || !selectedEmpty) return;
|
||||
|
||||
applyAnimation(selectedAnimation, selectedEmpty);
|
||||
const displayName = selectedEmpty.replace("controllable_", "");
|
||||
toast.success(`Applied ${type} animation to ${displayName}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium">{label}</h3>
|
||||
|
||||
{/* Empty selector */}
|
||||
{empties.length > 0 && (
|
||||
<Select
|
||||
value={selectedEmpty || ""}
|
||||
onValueChange={setSelectedEmpty}
|
||||
disabled={empties.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-32 h-7 text-xs">
|
||||
<SelectValue placeholder="Select target" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{empties.map((empty) => (
|
||||
<SelectItem key={empty} value={empty}>
|
||||
{empty.replace("controllable_", "")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No empties message */}
|
||||
{empties.length === 0 && (
|
||||
<div className="text-xs text-yellow-400 bg-yellow-400/10 p-2 rounded">
|
||||
{type === "product"
|
||||
? "No product empties available. Place a product first."
|
||||
: "No empties available."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Animations grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{typeAnimations.map((animation) => (
|
||||
<AnimationCard
|
||||
key={animation.id}
|
||||
animation={animation}
|
||||
isSelected={selectedAnimationId === animation.id}
|
||||
onSelect={(id) => selectAnimation(type, id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{typeAnimations.length === 0 && (
|
||||
<div className="col-span-2 text-center text-xs text-white/60 py-4">
|
||||
No animations available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Apply button */}
|
||||
<Button
|
||||
onClick={handleApplyAnimation}
|
||||
disabled={!canApply}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
Apply Animation
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
140
frontend/components/animations/AnimationSelector.tsx
Normal file
140
frontend/components/animations/AnimationSelector.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAnimations } from "@/hooks/useAnimations";
|
||||
import { Animation } from "@/lib/types/animations";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface AnimationSelectorProps {
|
||||
type: "camera" | "light" | "product";
|
||||
onApply?: (animation: Animation) => void;
|
||||
targetEmptyName?: string;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function AnimationSelector({
|
||||
type,
|
||||
onApply,
|
||||
targetEmptyName,
|
||||
className,
|
||||
label = "Select Animation",
|
||||
}: AnimationSelectorProps) {
|
||||
const [animations, setAnimations] = useState<Animation[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const { loadAnimations, applyAnimation } = useAnimations();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAnimations = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await loadAnimations(type);
|
||||
setAnimations(result);
|
||||
|
||||
// Auto-select the first animation if available
|
||||
if (result.length > 0 && !selectedId) {
|
||||
setSelectedId(result[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${type} animations:`, error);
|
||||
toast.error(`Failed to load ${type} animations`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAnimations();
|
||||
// Remove selectedId from dependencies to prevent potential infinite loops
|
||||
}, [type, loadAnimations]);
|
||||
|
||||
const handleSelectChange = (value: string) => {
|
||||
setSelectedId(value);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (!selectedId) {
|
||||
toast.error("Please select an animation first");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetEmptyName && !onApply) {
|
||||
toast.error("No target specified for animation");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedAnimation = animations.find((anim) => anim.id === selectedId);
|
||||
if (!selectedAnimation) {
|
||||
toast.error("Selected animation not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (onApply) {
|
||||
onApply(selectedAnimation);
|
||||
} else if (targetEmptyName) {
|
||||
applyAnimation(selectedAnimation, targetEmptyName);
|
||||
toast.success(`Applied ${type} animation: ${selectedAnimation.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = () => {
|
||||
switch (type) {
|
||||
case "camera":
|
||||
return "Camera";
|
||||
case "light":
|
||||
return "Lighting";
|
||||
case "product":
|
||||
return "Product";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`animation-selector flex flex-col gap-2 ${className || ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={selectedId}
|
||||
onValueChange={handleSelectChange}
|
||||
disabled={isLoading || animations.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={`${label} (${getTypeLabel()})`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{animations.map((animation) => (
|
||||
<SelectItem key={animation.id} value={animation.id}>
|
||||
{animation.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{animations.length === 0 && !isLoading && (
|
||||
<SelectItem value="none" disabled>
|
||||
No animations available
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={!selectedId || isLoading}
|
||||
size="sm"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<p className="text-xs text-gray-500">Loading animations...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
201
frontend/components/animations/AnimationsControl.tsx
Normal file
201
frontend/components/animations/AnimationsControl.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useAnimationStore } from "@/store/animationStore";
|
||||
import { useTemplateControlsStore } from "@/store/TemplateControlsStore";
|
||||
import { useAssetPlacerStore } from "@/store/assetPlacerStore";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import { AnimationDialog } from "./AnimationDialog";
|
||||
|
||||
export function AnimationsControl() {
|
||||
// Animation type filter
|
||||
const [selectedType, setSelectedType] = useState<
|
||||
"camera" | "light" | "product"
|
||||
>("camera");
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 4;
|
||||
|
||||
// Dialog state
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
// Target empty selection
|
||||
const [selectedEmpty, setSelectedEmpty] = useState<string | null>(null);
|
||||
|
||||
// Get animations from store
|
||||
const { animations } = useAnimationStore();
|
||||
|
||||
// Get empties
|
||||
const controls = useTemplateControlsStore((state) => state.controls);
|
||||
const placedAssets = useAssetPlacerStore((state) => state.placedAssets);
|
||||
|
||||
// Filtered animations for the selected type
|
||||
const filteredAnimations = useMemo(() => {
|
||||
// Map product_animation to product for consistency with our UI
|
||||
if (selectedType === "product") {
|
||||
return animations.product || [];
|
||||
}
|
||||
return animations[selectedType] || [];
|
||||
}, [animations, selectedType]);
|
||||
|
||||
// Total pages
|
||||
const totalPages = Math.ceil(filteredAnimations.length / itemsPerPage);
|
||||
|
||||
// Paginated animations for current view
|
||||
const displayedAnimations = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
return filteredAnimations.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [filteredAnimations, currentPage]);
|
||||
|
||||
// Get appropriate empties based on animation type
|
||||
const availableEmpties = useMemo(() => {
|
||||
const allEmpties = (controls?.objects || [])
|
||||
.filter((obj) => obj.object_type === "EMPTY")
|
||||
.map((obj) => obj.name);
|
||||
|
||||
// For product animations, only use empties with products
|
||||
if (selectedType === "product") {
|
||||
return allEmpties.filter((emptyName) =>
|
||||
placedAssets.some((asset) => asset.emptyName === emptyName)
|
||||
);
|
||||
}
|
||||
|
||||
return allEmpties;
|
||||
}, [controls, placedAssets, selectedType]);
|
||||
|
||||
// Auto-select first empty if available and none selected
|
||||
useMemo(() => {
|
||||
if (availableEmpties.length > 0 && !selectedEmpty) {
|
||||
setSelectedEmpty(availableEmpties[0]);
|
||||
} else if (availableEmpties.length === 0) {
|
||||
setSelectedEmpty(null);
|
||||
}
|
||||
}, [availableEmpties, selectedEmpty]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter controls */}
|
||||
<div className="flex justify-between gap-2">
|
||||
<Select
|
||||
value={selectedType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedType(value as "camera" | "light" | "product");
|
||||
setCurrentPage(1); // Reset to first page on type change
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Animation type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="camera">Camera</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="product">Product</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={selectedEmpty || ""}
|
||||
onValueChange={setSelectedEmpty}
|
||||
disabled={availableEmpties.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Select target" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableEmpties.map((empty) => (
|
||||
<SelectItem key={empty} value={empty}>
|
||||
{empty.replace("controllable_", "")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* No empties warning */}
|
||||
{availableEmpties.length === 0 && (
|
||||
<div className="text-xs text-yellow-400 bg-yellow-400/10 p-2 rounded">
|
||||
{selectedType === "product"
|
||||
? "No product empties available. Place a product first."
|
||||
: "No empties available."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Animation grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{displayedAnimations.map((animation) => (
|
||||
<AnimationCard
|
||||
key={animation.id}
|
||||
animation={animation}
|
||||
targetEmpty={selectedEmpty}
|
||||
isSelected={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredAnimations.length === 0 && (
|
||||
<div className="col-span-2 text-center text-xs text-white/60 py-4">
|
||||
No animations available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{filteredAnimations.length > 0 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-xs">
|
||||
Page {currentPage} of {Math.min(totalPages, 3)}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setCurrentPage((p) => Math.min(3, totalPages, p + 1))
|
||||
}
|
||||
disabled={
|
||||
currentPage === Math.min(3, totalPages) || totalPages === 0
|
||||
}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show all button (only if more than 12 animations) */}
|
||||
{filteredAnimations.length > 12 && (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full">
|
||||
Show All Animations
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<AnimationDialog
|
||||
animations={filteredAnimations}
|
||||
animationType={selectedType}
|
||||
targetEmpty={selectedEmpty}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,6 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useAssetPlacerStore } from "@/store/assetPlacerStore";
|
||||
import { useAssetPlacer } from "@/hooks/useAssetPlacer";
|
||||
|
||||
@@ -12,7 +18,16 @@ export function AssetControls({ assetId }: AssetControlsProps) {
|
||||
const placedAsset = useAssetPlacerStore((state) =>
|
||||
state.placedAssets.find((a) => a.assetId === assetId)
|
||||
);
|
||||
const { rotateAsset, scaleAsset, removeAsset } = useAssetPlacer();
|
||||
// Get all available assets once
|
||||
const availableAssets = useAssetPlacerStore((state) => state.availableAssets);
|
||||
|
||||
// Create filtered assets list with useMemo
|
||||
const replacementAssets = useMemo(() => {
|
||||
return availableAssets.filter((a) => a.id !== assetId);
|
||||
}, [availableAssets, assetId]);
|
||||
|
||||
const { rotateAsset, scaleAsset, removeAsset, replaceAsset } =
|
||||
useAssetPlacer();
|
||||
|
||||
const [rotation, setRotation] = useState(placedAsset?.rotation || 0);
|
||||
const [scale, setScale] = useState(placedAsset?.scale || 100);
|
||||
@@ -75,7 +90,24 @@ export function AssetControls({ assetId }: AssetControlsProps) {
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Button className="bg-[#0077B6] hover:bg-[#0077B6]/80">Replace</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="bg-[#0077B6] hover:bg-[#0077B6]/80">
|
||||
Replace
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-[#1C1C1C] border-white/20">
|
||||
{replacementAssets.map((asset) => (
|
||||
<DropdownMenuItem
|
||||
key={asset.id}
|
||||
onClick={() => replaceAsset(assetId, asset.id)}
|
||||
className="text-white hover:bg-white/10"
|
||||
>
|
||||
{asset.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -24,7 +24,9 @@ export function EmptyPositionSelector({ assetId }: EmptyPositionSelectorProps) {
|
||||
|
||||
// Filter empty objects - memoized so it only recalculates when controls change
|
||||
const empties = useMemo(() => {
|
||||
return (controls?.objects || []).filter((obj) => obj.type === "EMPTY");
|
||||
return (controls?.objects || []).filter(
|
||||
(obj) => obj.object_type === "EMPTY"
|
||||
);
|
||||
}, [controls]);
|
||||
|
||||
const { placeAsset } = useAssetPlacer();
|
||||
@@ -48,7 +50,7 @@ export function EmptyPositionSelector({ assetId }: EmptyPositionSelectorProps) {
|
||||
<SelectContent className="bg-[#1C1C1C] border-white/20">
|
||||
{empties.map((empty) => (
|
||||
<SelectItem key={empty.name} value={empty.name}>
|
||||
{empty.name}
|
||||
{empty.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
@@ -1,7 +1,17 @@
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Sun, Camera, Layers, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
Sun,
|
||||
Camera,
|
||||
Layers,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Play,
|
||||
Repeat,
|
||||
} from "lucide-react";
|
||||
import { AnimationsControl } from "@/components/animations/AnimationsControl";
|
||||
import { SwapAssetsControl } from "./SwapAssetsControl";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -69,7 +79,7 @@ export function SceneControls({
|
||||
|
||||
const controls: ControlItem[] = [
|
||||
{
|
||||
name: "Light Controls",
|
||||
name: "Light",
|
||||
icon: <Sun className="h-5 w-5 mr-2" />,
|
||||
color: "#FFD100",
|
||||
control: ({
|
||||
@@ -96,7 +106,7 @@ export function SceneControls({
|
||||
<SelectContent>
|
||||
{templateControls?.lights.map((light) => (
|
||||
<SelectItem key={light.name} value={light.name}>
|
||||
{light.name}
|
||||
{light.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -180,7 +190,7 @@ export function SceneControls({
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Camera Controls",
|
||||
name: "Camera",
|
||||
icon: <Camera className="h-5 w-5 mr-2" />,
|
||||
color: "#0077B6",
|
||||
control: (
|
||||
@@ -197,7 +207,7 @@ export function SceneControls({
|
||||
<SelectContent>
|
||||
{templateControls?.cameras.map((camera) => (
|
||||
<SelectItem key={camera.name} value={camera.name}>
|
||||
{camera.name}
|
||||
{camera.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -206,28 +216,16 @@ export function SceneControls({
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Variation",
|
||||
icon: <Layers className="h-5 w-5 mr-2" />,
|
||||
color: "#FF006E",
|
||||
control: (
|
||||
<div className="flex space-x-2 mt-2">
|
||||
{[
|
||||
{ value: "V1", label: "V1" },
|
||||
{ value: "V2", label: "V2" },
|
||||
{ value: "V3", label: "V3" },
|
||||
].map((item, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => console.log(item.value)} // Replace with actual logic
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
name: "Animations",
|
||||
icon: <Play className="h-5 w-5 mr-2" />,
|
||||
color: "#10B981",
|
||||
control: <AnimationsControl />,
|
||||
},
|
||||
{
|
||||
name: "Swap Assets",
|
||||
icon: <Repeat className="h-5 w-5 mr-2" />,
|
||||
color: "#9333EA",
|
||||
control: <SwapAssetsControl />,
|
||||
},
|
||||
];
|
||||
|
||||
|
91
frontend/components/creative-workspace/SwapAssetsControl.tsx
Normal file
91
frontend/components/creative-workspace/SwapAssetsControl.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAssetPlacerStore } from "@/store/assetPlacerStore";
|
||||
import { useAssetPlacer } from "@/hooks/useAssetPlacer";
|
||||
|
||||
export function SwapAssetsControl() {
|
||||
const [selectedAsset1, setSelectedAsset1] = useState<string | null>(null);
|
||||
const [selectedAsset2, setSelectedAsset2] = useState<string | null>(null);
|
||||
|
||||
// Get store data at component level
|
||||
const placedAssets = useAssetPlacerStore((state) => state.placedAssets);
|
||||
const availableAssets = useAssetPlacerStore((state) => state.availableAssets);
|
||||
const { swapAssets } = useAssetPlacer();
|
||||
|
||||
// Memoize the asset name lookup function
|
||||
const getAssetName = useMemo(() => {
|
||||
return (assetId: string) => {
|
||||
return (
|
||||
availableAssets.find((a) => a.id === assetId)?.name || "Unknown Asset"
|
||||
);
|
||||
};
|
||||
}, [availableAssets]);
|
||||
|
||||
// Memoize the filtered assets for the second dropdown
|
||||
const filteredAssets = useMemo(() => {
|
||||
if (!selectedAsset1) return [];
|
||||
return placedAssets.filter((asset) => asset.assetId !== selectedAsset1);
|
||||
}, [placedAssets, selectedAsset1]);
|
||||
|
||||
return (
|
||||
<div className="backdrop-blur-md bg-white/5 p-4 rounded-lg space-y-4">
|
||||
<h3 className="font-medium text-white">Swap Assets</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">First Asset</label>
|
||||
<Select onValueChange={(value) => setSelectedAsset1(value)}>
|
||||
<SelectTrigger className="bg-white/10 border-white/20">
|
||||
<SelectValue placeholder="Select first asset" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1C1C1C] border-white/20">
|
||||
{placedAssets.map((asset) => (
|
||||
<SelectItem key={asset.assetId} value={asset.assetId}>
|
||||
{getAssetName(asset.assetId)} ({asset.emptyName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Second Asset</label>
|
||||
<Select
|
||||
onValueChange={(value) => setSelectedAsset2(value)}
|
||||
disabled={!selectedAsset1}
|
||||
>
|
||||
<SelectTrigger className="bg-white/10 border-white/20">
|
||||
<SelectValue placeholder="Select second asset" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1C1C1C] border-white/20">
|
||||
{filteredAssets.map((asset) => (
|
||||
<SelectItem key={asset.assetId} value={asset.assetId}>
|
||||
{getAssetName(asset.assetId)} ({asset.emptyName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!selectedAsset1 || !selectedAsset2}
|
||||
onClick={() => {
|
||||
if (selectedAsset1 && selectedAsset2) {
|
||||
swapAssets(selectedAsset1, selectedAsset2);
|
||||
setSelectedAsset1(null);
|
||||
setSelectedAsset2(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Swap Assets
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
23
frontend/components/ui/separator.tsx
Normal file
23
frontend/components/ui/separator.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SeparatorProps {
|
||||
className?: string;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
}: SeparatorProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
role="separator"
|
||||
/>
|
||||
);
|
||||
}
|
@@ -1,11 +1,28 @@
|
||||
import { createContext, useContext, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useWebSocket } from "@/hooks/useWebsocket";
|
||||
import { WebSocketStatus, WebSocketMessage } from "@/lib/types/websocket";
|
||||
import {
|
||||
WebSocketStatus,
|
||||
WebSocketMessage,
|
||||
AnimationResponseMessage,
|
||||
} from "@/lib/types/websocket";
|
||||
import { processWebSocketMessage } from "@/lib/handlers/websocketMessageHandler";
|
||||
import { createAnimationHandler } from "@/hooks/useAnimationWebSocket";
|
||||
import { useTemplateControlsStore } from "@/store/TemplateControlsStore";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface WebSocketContextType {
|
||||
status: WebSocketStatus;
|
||||
websocket: WebSocket | null;
|
||||
isConnected: boolean;
|
||||
isConnected: boolean; // Browser connection only
|
||||
blenderConnected: boolean; // Blender connection only
|
||||
isFullyConnected: boolean; // Both browser and Blender are connected
|
||||
reconnect: () => void;
|
||||
disconnect: () => void;
|
||||
sendMessage: (message: WebSocketMessage) => void;
|
||||
@@ -23,10 +40,149 @@ export function WebSocketProvider({
|
||||
children,
|
||||
onMessage,
|
||||
}: WebSocketProviderProps) {
|
||||
const wsHook = useWebSocket(onMessage);
|
||||
const setControls = useTemplateControlsStore((state) => state.setControls);
|
||||
|
||||
// Add state for tracking Blender connection
|
||||
const [blenderConnected, setBlenderConnected] = useState(false);
|
||||
// Add state to track if we've already reconnected to an existing Blender session
|
||||
const [alreadyReconnected, setAlreadyReconnected] = useState(false);
|
||||
|
||||
// Use a ref to solve the circular dependency
|
||||
const animationHandlersRef = useRef<any>(null);
|
||||
|
||||
// Define message processing function
|
||||
const processMessage = useCallback(
|
||||
(data: any) => {
|
||||
// Check for Blender connection/disconnection messages
|
||||
if (data.type === "system" && data.status === "blender_connected") {
|
||||
setBlenderConnected(true);
|
||||
// Show reconnection message if it's a reconnection to existing instance
|
||||
if (data.message?.includes("Reconnected to existing")) {
|
||||
setAlreadyReconnected(true);
|
||||
toast.success("Reconnected to existing Blender session");
|
||||
}
|
||||
} else if (
|
||||
data.type === "system" &&
|
||||
data.status === "blender_disconnected"
|
||||
) {
|
||||
setBlenderConnected(false);
|
||||
} else if (
|
||||
data.type === "system" &&
|
||||
data.status === "waiting_for_blender"
|
||||
) {
|
||||
setBlenderConnected(false);
|
||||
toast.info(data.message || "Waiting for Blender to connect...");
|
||||
} else if (
|
||||
data.type === "system" &&
|
||||
data.status === "error" &&
|
||||
data.message?.includes("Failed to launch Blender")
|
||||
) {
|
||||
setBlenderConnected(false);
|
||||
toast.error(data.message);
|
||||
|
||||
// Attempt auto-recovery after a short delay
|
||||
setTimeout(() => {
|
||||
if (wsHookWithHandler.websocket?.readyState === WebSocket.OPEN) {
|
||||
console.log("Attempting to recover Blender connection");
|
||||
wsHookWithHandler.sendMessage({
|
||||
command: "browser_ready",
|
||||
recovery: true,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Process the message using our handler
|
||||
processWebSocketMessage(data, {
|
||||
// Handle animation responses
|
||||
onAnimationResponse: (data: AnimationResponseMessage) => {
|
||||
if (animationHandlersRef.current) {
|
||||
animationHandlersRef.current.handleAnimationResponse(data.data);
|
||||
}
|
||||
},
|
||||
|
||||
// Handle template controls response
|
||||
onTemplateControls: (data) => {
|
||||
// Check for controls in the correct location (data.data.controls)
|
||||
if (data.data?.controls) {
|
||||
// Transform flat list into categorized structure
|
||||
const categorized = {
|
||||
cameras: data.data.controls.filter(
|
||||
(c: any) => c.type === "camera"
|
||||
),
|
||||
lights: data.data.controls.filter((c: any) => c.type === "light"),
|
||||
materials: data.data.controls.filter(
|
||||
(c: any) => c.type === "material"
|
||||
),
|
||||
objects: data.data.controls.filter(
|
||||
(c: any) => c.type === "object"
|
||||
),
|
||||
};
|
||||
setControls(categorized);
|
||||
|
||||
// Show success toast
|
||||
toast.success("Template controls loaded");
|
||||
} else {
|
||||
console.error(
|
||||
"No controls found in template_controls_result",
|
||||
data
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// Forward to custom handler if provided
|
||||
onCustomMessage: onMessage,
|
||||
});
|
||||
|
||||
// Also forward to the original onMessage handler if provided
|
||||
if (onMessage) {
|
||||
onMessage(data);
|
||||
}
|
||||
},
|
||||
[onMessage, setControls]
|
||||
);
|
||||
|
||||
// Use the WebSocket hook with our custom message handler
|
||||
const wsHookWithHandler = useWebSocket((data: any) => {
|
||||
// Process the message first to potentially set alreadyReconnected
|
||||
processMessage(data);
|
||||
|
||||
// Check for initial connection confirmation
|
||||
if (data.status === "connected" && data.message === "Session created") {
|
||||
// Signal that browser is ready for Blender to connect, but only if we haven't already reconnected
|
||||
setTimeout(() => {
|
||||
if (
|
||||
!alreadyReconnected &&
|
||||
wsHookWithHandler.websocket?.readyState === WebSocket.OPEN
|
||||
) {
|
||||
console.log("Sending browser_ready signal");
|
||||
wsHookWithHandler.sendMessage({
|
||||
command: "browser_ready",
|
||||
});
|
||||
} else if (alreadyReconnected) {
|
||||
console.log(
|
||||
"Skipping browser_ready signal - already reconnected to Blender"
|
||||
);
|
||||
}
|
||||
}, 500); // Small delay to ensure everything is set up
|
||||
}
|
||||
});
|
||||
|
||||
// Create animation handlers with the sendMessage function and store in ref
|
||||
const animationHandlers = createAnimationHandler(
|
||||
wsHookWithHandler.sendMessage
|
||||
);
|
||||
animationHandlersRef.current = animationHandlers;
|
||||
|
||||
// Create the context value with the combined connection state
|
||||
const contextValue = {
|
||||
...wsHookWithHandler,
|
||||
blenderConnected,
|
||||
isFullyConnected: wsHookWithHandler.isConnected && blenderConnected,
|
||||
};
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={wsHook}>
|
||||
<WebSocketContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
|
54
frontend/hooks/useAnimationWebSocket.ts
Normal file
54
frontend/hooks/useAnimationWebSocket.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useCallback } from "react";
|
||||
import { Animation } from "@/lib/types/animations";
|
||||
import { toast } from "sonner";
|
||||
import { WebSocketMessage } from "@/lib/types/websocket";
|
||||
|
||||
/**
|
||||
* Factory function to create animation handlers with a given sendMessage function
|
||||
* This breaks the circular dependency between WebSocketContext and useAnimations
|
||||
*/
|
||||
export function createAnimationHandler(
|
||||
sendMessageFn: (message: WebSocketMessage) => void
|
||||
) {
|
||||
return {
|
||||
/**
|
||||
* Apply an animation to a target empty
|
||||
*/
|
||||
applyAnimation: (animation: Animation, emptyName: string) => {
|
||||
try {
|
||||
// Create a unique message ID for tracking
|
||||
const messageId = `anim_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Determine the command based on animation type
|
||||
const command = `load_${animation.template_type}_animation`;
|
||||
|
||||
// Send the animation command via WebSocket with just the ID
|
||||
// The middleware will fetch the full template data
|
||||
sendMessageFn({
|
||||
command,
|
||||
empty_name: emptyName,
|
||||
animation_id: animation.id,
|
||||
message_id: messageId,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
`Applying ${animation.template_type} animation: ${animation.name}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error applying animation:", error);
|
||||
toast.error("Failed to apply animation");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle animation response from WebSocket
|
||||
*/
|
||||
handleAnimationResponse: (data: any) => {
|
||||
if (data.success) {
|
||||
toast.success("Animation applied successfully");
|
||||
} else {
|
||||
toast.error(`Animation failed: ${data.message || "Unknown error"}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
36
frontend/hooks/useAnimations.ts
Normal file
36
frontend/hooks/useAnimations.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useCallback } from "react";
|
||||
import { useWebSocketContext } from "@/contexts/WebSocketContext";
|
||||
import { Animation } from "@/lib/types/animations";
|
||||
import { toast } from "sonner";
|
||||
import { fetchAnimations } from "@/lib/services/animationService";
|
||||
import { createAnimationHandler } from "./useAnimationWebSocket";
|
||||
|
||||
export function useAnimations() {
|
||||
const { sendMessage } = useWebSocketContext();
|
||||
|
||||
// Use the animation handler factory to create handlers
|
||||
const animationHandlers = createAnimationHandler(sendMessage);
|
||||
|
||||
/**
|
||||
* Load animations of a specific type from the server
|
||||
*/
|
||||
const loadAnimations = useCallback(
|
||||
async (type: "camera" | "light" | "product"): Promise<Animation[]> => {
|
||||
try {
|
||||
const animations = await fetchAnimations(type);
|
||||
return animations;
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${type} animations:`, error);
|
||||
toast.error(`Failed to load ${type} animations`);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
loadAnimations,
|
||||
applyAnimation: animationHandlers.applyAnimation,
|
||||
handleAnimationResponse: animationHandlers.handleAnimationResponse,
|
||||
};
|
||||
}
|
@@ -96,10 +96,81 @@ export function useAssetPlacer() {
|
||||
[getEmptyNameForAsset, sendMessage, updatePlacedAssetProperties]
|
||||
);
|
||||
|
||||
const replaceAsset = useCallback(
|
||||
(currentAssetId: string, newAssetId: string) => {
|
||||
const emptyName = getEmptyNameForAsset(currentAssetId);
|
||||
if (!emptyName) {
|
||||
toast.error("Asset placement not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const newAsset = availableAssets.find((a) => a.id === newAssetId);
|
||||
if (!newAsset) {
|
||||
toast.error("Replacement asset not found");
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
command: "append_asset",
|
||||
empty_name: emptyName,
|
||||
filepath: newAsset.filepath,
|
||||
asset_name: newAsset.name,
|
||||
mode: "REPLACE",
|
||||
});
|
||||
|
||||
// Update local state optimistically
|
||||
removePlacedAssetState(currentAssetId);
|
||||
updatePlacedAssetState(newAssetId, emptyName);
|
||||
toast.info(`Replacing with ${newAsset.name} at ${emptyName}`);
|
||||
},
|
||||
[
|
||||
availableAssets,
|
||||
sendMessage,
|
||||
getEmptyNameForAsset,
|
||||
removePlacedAssetState,
|
||||
updatePlacedAssetState,
|
||||
]
|
||||
);
|
||||
|
||||
const swapAssets = useCallback(
|
||||
(assetId1: string, assetId2: string) => {
|
||||
const emptyName1 = getEmptyNameForAsset(assetId1);
|
||||
const emptyName2 = getEmptyNameForAsset(assetId2);
|
||||
|
||||
if (!emptyName1 || !emptyName2) {
|
||||
toast.error("One or both asset placements not found");
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
command: "swap_assets",
|
||||
empty1_name: emptyName1,
|
||||
empty2_name: emptyName2,
|
||||
});
|
||||
|
||||
// Update local state optimistically
|
||||
const asset1 = { ...placedAssets.find((a) => a.assetId === assetId1)! };
|
||||
const asset2 = { ...placedAssets.find((a) => a.assetId === assetId2)! };
|
||||
|
||||
updatePlacedAssetProperties(assetId1, { emptyName: emptyName2 });
|
||||
updatePlacedAssetProperties(assetId2, { emptyName: emptyName1 });
|
||||
|
||||
toast.info(`Swapping assets between ${emptyName1} and ${emptyName2}`);
|
||||
},
|
||||
[
|
||||
sendMessage,
|
||||
getEmptyNameForAsset,
|
||||
placedAssets,
|
||||
updatePlacedAssetProperties,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
placeAsset,
|
||||
removeAsset,
|
||||
rotateAsset,
|
||||
scaleAsset,
|
||||
replaceAsset,
|
||||
swapAssets,
|
||||
};
|
||||
}
|
||||
|
28
frontend/hooks/useCameraControl.ts
Normal file
28
frontend/hooks/useCameraControl.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from "react";
|
||||
import { useWebSocket } from "./useWebsocket";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function useCameraControl() {
|
||||
const { sendMessage } = useWebSocket();
|
||||
|
||||
/**
|
||||
* Update the active camera
|
||||
* @param cameraName The name of the camera to set as active
|
||||
*/
|
||||
const updateCamera = useCallback(
|
||||
(cameraName: string) => {
|
||||
const messageId = uuidv4();
|
||||
sendMessage({
|
||||
command: "update_camera",
|
||||
camera_name: cameraName,
|
||||
message_id: messageId,
|
||||
});
|
||||
return messageId;
|
||||
},
|
||||
[sendMessage]
|
||||
);
|
||||
|
||||
return {
|
||||
updateCamera,
|
||||
};
|
||||
}
|
32
frontend/hooks/useLightControl.ts
Normal file
32
frontend/hooks/useLightControl.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from "react";
|
||||
import { useWebSocket } from "./useWebsocket";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function useLightControl() {
|
||||
const { sendMessage } = useWebSocket();
|
||||
|
||||
/**
|
||||
* Update a light's properties
|
||||
* @param lightName The name of the light to update
|
||||
* @param color Optional color value (RGB array or hex string)
|
||||
* @param strength Optional light strength/intensity value
|
||||
*/
|
||||
const updateLight = useCallback(
|
||||
(lightName: string, color?: string | number[], strength?: number) => {
|
||||
const messageId = uuidv4();
|
||||
sendMessage({
|
||||
command: "update_light",
|
||||
light_name: lightName,
|
||||
color,
|
||||
strength,
|
||||
message_id: messageId,
|
||||
});
|
||||
return messageId;
|
||||
},
|
||||
[sendMessage]
|
||||
);
|
||||
|
||||
return {
|
||||
updateLight,
|
||||
};
|
||||
}
|
39
frontend/hooks/useMaterialControl.ts
Normal file
39
frontend/hooks/useMaterialControl.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useCallback } from "react";
|
||||
import { useWebSocket } from "./useWebsocket";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function useMaterialControl() {
|
||||
const { sendMessage } = useWebSocket();
|
||||
|
||||
/**
|
||||
* Update a material's properties
|
||||
* @param materialName The name of the material to update
|
||||
* @param color Optional color value (RGB array or hex string)
|
||||
* @param roughness Optional roughness value (0-1)
|
||||
* @param metallic Optional metallic value (0-1)
|
||||
*/
|
||||
const updateMaterial = useCallback(
|
||||
(
|
||||
materialName: string,
|
||||
color?: string | number[],
|
||||
roughness?: number,
|
||||
metallic?: number
|
||||
) => {
|
||||
const messageId = uuidv4();
|
||||
sendMessage({
|
||||
command: "update_material",
|
||||
material_name: materialName,
|
||||
color,
|
||||
roughness,
|
||||
metallic,
|
||||
message_id: messageId,
|
||||
});
|
||||
return messageId;
|
||||
},
|
||||
[sendMessage]
|
||||
);
|
||||
|
||||
return {
|
||||
updateMaterial,
|
||||
};
|
||||
}
|
39
frontend/hooks/useObjectControl.ts
Normal file
39
frontend/hooks/useObjectControl.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useCallback } from "react";
|
||||
import { useWebSocket } from "./useWebsocket";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function useObjectControl() {
|
||||
const { sendMessage } = useWebSocket();
|
||||
|
||||
/**
|
||||
* Update an object's transform properties
|
||||
* @param objectName The name of the object to update
|
||||
* @param location Optional location/position (XYZ array)
|
||||
* @param rotation Optional rotation (XYZ array in degrees or radians)
|
||||
* @param scale Optional scale (XYZ array or uniform scale)
|
||||
*/
|
||||
const updateObject = useCallback(
|
||||
(
|
||||
objectName: string,
|
||||
location?: number[],
|
||||
rotation?: number[],
|
||||
scale?: number[]
|
||||
) => {
|
||||
const messageId = uuidv4();
|
||||
sendMessage({
|
||||
command: "update_object",
|
||||
object_name: objectName,
|
||||
location,
|
||||
rotation,
|
||||
scale,
|
||||
message_id: messageId,
|
||||
});
|
||||
return messageId;
|
||||
},
|
||||
[sendMessage]
|
||||
);
|
||||
|
||||
return {
|
||||
updateObject,
|
||||
};
|
||||
}
|
@@ -48,21 +48,17 @@ export const usePreviewRenderer = (
|
||||
[checkConnection]
|
||||
);
|
||||
|
||||
const shootPreview = useCallback(
|
||||
(sceneConfiguration: any, resetSceneConfiguration: () => void) => {
|
||||
if (!checkConnection()) return;
|
||||
const shootPreview = useCallback(() => {
|
||||
if (!checkConnection()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setIsPreviewAvailable(false);
|
||||
sendMessage({
|
||||
command: "start_preview_rendering",
|
||||
params: sceneConfiguration,
|
||||
});
|
||||
toast.info("Starting preview rendering...");
|
||||
resetSceneConfiguration();
|
||||
},
|
||||
[checkConnection, sendMessage]
|
||||
);
|
||||
setIsLoading(true);
|
||||
setIsPreviewAvailable(false);
|
||||
sendMessage({
|
||||
command: "start_preview_rendering",
|
||||
params: {}, // Empty params since scene updates are sent separately
|
||||
});
|
||||
toast.info("Starting preview rendering...");
|
||||
}, [checkConnection, sendMessage]);
|
||||
|
||||
const playbackPreview = useCallback(() => {
|
||||
if (!checkConnection()) return;
|
||||
@@ -155,6 +151,6 @@ export const usePreviewRenderer = (
|
||||
shootPreview,
|
||||
playbackPreview,
|
||||
stopPlaybackPreview,
|
||||
generateVideo,
|
||||
generateVideo,
|
||||
};
|
||||
};
|
||||
|
218
frontend/hooks/useSceneControls.ts
Normal file
218
frontend/hooks/useSceneControls.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSceneConfigStore } from "@/store/sceneConfiguratorStore";
|
||||
import { useCameraControl } from "./useCameraControl";
|
||||
import { useLightControl } from "./useLightControl";
|
||||
import { useMaterialControl } from "./useMaterialControl";
|
||||
import { useObjectControl } from "./useObjectControl";
|
||||
|
||||
/**
|
||||
* Hook that combines scene configuration store with direct control hooks
|
||||
* for real-time updates to scene elements
|
||||
*/
|
||||
export function useSceneControls() {
|
||||
// Get store methods
|
||||
const {
|
||||
sceneConfiguration,
|
||||
updateSceneConfiguration,
|
||||
removeSceneConfiguration,
|
||||
setSceneConfiguration,
|
||||
resetSceneConfiguration,
|
||||
} = useSceneConfigStore();
|
||||
|
||||
// Get direct control hooks
|
||||
const { updateCamera } = useCameraControl();
|
||||
const { updateLight } = useLightControl();
|
||||
const { updateMaterial } = useMaterialControl();
|
||||
const { updateObject } = useObjectControl();
|
||||
|
||||
// Camera control
|
||||
const handleUpdateCamera = useCallback(
|
||||
(cameraName: string) => {
|
||||
// Update store
|
||||
updateSceneConfiguration("camera", { camera_name: cameraName });
|
||||
|
||||
// Send direct update via WebSocket
|
||||
updateCamera(cameraName);
|
||||
},
|
||||
[updateSceneConfiguration, updateCamera]
|
||||
);
|
||||
|
||||
// Light control
|
||||
const handleUpdateLight = useCallback(
|
||||
(lightName: string, color?: string | number[], strength?: number) => {
|
||||
// Update store
|
||||
const currentLights = sceneConfiguration.lights || [];
|
||||
const existingLightIndex = currentLights.findIndex(
|
||||
(l) => l.light_name === lightName
|
||||
);
|
||||
|
||||
let updatedLights;
|
||||
if (existingLightIndex >= 0) {
|
||||
// Update existing light
|
||||
updatedLights = [...currentLights];
|
||||
const updatedLight = { ...updatedLights[existingLightIndex] };
|
||||
|
||||
if (color !== undefined) {
|
||||
updatedLight.color =
|
||||
typeof color === "string" ? color : `rgb(${color.join(",")})`;
|
||||
}
|
||||
|
||||
if (strength !== undefined) {
|
||||
updatedLight.strength = strength;
|
||||
}
|
||||
|
||||
updatedLights[existingLightIndex] = updatedLight;
|
||||
} else {
|
||||
// Add new light
|
||||
updatedLights = [
|
||||
...currentLights,
|
||||
{
|
||||
light_name: lightName,
|
||||
color:
|
||||
typeof color === "string"
|
||||
? color
|
||||
: color
|
||||
? `rgb(${color.join(",")})`
|
||||
: "#FFFFFF",
|
||||
strength: strength || 1.0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
updateSceneConfiguration("lights", updatedLights);
|
||||
|
||||
// Send direct update via WebSocket
|
||||
updateLight(lightName, color, strength);
|
||||
},
|
||||
[sceneConfiguration, updateSceneConfiguration, updateLight]
|
||||
);
|
||||
|
||||
// Material control
|
||||
const handleUpdateMaterial = useCallback(
|
||||
(
|
||||
materialName: string,
|
||||
color?: string | number[],
|
||||
roughness?: number,
|
||||
metallic?: number
|
||||
) => {
|
||||
// Update store
|
||||
const currentMaterials = sceneConfiguration.materials || [];
|
||||
const existingMaterialIndex = currentMaterials.findIndex(
|
||||
(m) => m.material_name === materialName
|
||||
);
|
||||
|
||||
let updatedMaterials;
|
||||
if (existingMaterialIndex >= 0) {
|
||||
// Update existing material
|
||||
updatedMaterials = [...currentMaterials];
|
||||
const updatedMaterial = { ...updatedMaterials[existingMaterialIndex] };
|
||||
|
||||
if (color !== undefined) {
|
||||
updatedMaterial.color =
|
||||
typeof color === "string" ? color : `rgb(${color.join(",")})`;
|
||||
}
|
||||
|
||||
if (roughness !== undefined) {
|
||||
updatedMaterial.roughness = roughness;
|
||||
}
|
||||
|
||||
if (metallic !== undefined) {
|
||||
updatedMaterial.metallic = metallic;
|
||||
}
|
||||
|
||||
updatedMaterials[existingMaterialIndex] = updatedMaterial;
|
||||
} else {
|
||||
// Add new material
|
||||
updatedMaterials = [
|
||||
...currentMaterials,
|
||||
{
|
||||
material_name: materialName,
|
||||
color:
|
||||
typeof color === "string"
|
||||
? color
|
||||
: color
|
||||
? `rgb(${color.join(",")})`
|
||||
: "#FFFFFF",
|
||||
roughness,
|
||||
metallic,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
updateSceneConfiguration("materials", updatedMaterials);
|
||||
|
||||
// Send direct update via WebSocket
|
||||
updateMaterial(materialName, color, roughness, metallic);
|
||||
},
|
||||
[sceneConfiguration, updateSceneConfiguration, updateMaterial]
|
||||
);
|
||||
|
||||
// Object control
|
||||
const handleUpdateObject = useCallback(
|
||||
(
|
||||
objectName: string,
|
||||
location?: number[],
|
||||
rotation?: number[],
|
||||
scale?: number[]
|
||||
) => {
|
||||
// Update store
|
||||
const currentObjects = sceneConfiguration.objects || [];
|
||||
const existingObjectIndex = currentObjects.findIndex(
|
||||
(o) => o.object_name === objectName
|
||||
);
|
||||
|
||||
let updatedObjects;
|
||||
if (existingObjectIndex >= 0) {
|
||||
// Update existing object
|
||||
updatedObjects = [...currentObjects];
|
||||
const updatedObject = { ...updatedObjects[existingObjectIndex] };
|
||||
|
||||
if (location !== undefined) {
|
||||
updatedObject.location = location as [number, number, number];
|
||||
}
|
||||
|
||||
if (rotation !== undefined) {
|
||||
updatedObject.rotation = rotation as [number, number, number];
|
||||
}
|
||||
|
||||
if (scale !== undefined) {
|
||||
updatedObject.scale = scale as [number, number, number];
|
||||
}
|
||||
|
||||
updatedObjects[existingObjectIndex] = updatedObject;
|
||||
} else {
|
||||
// Add new object
|
||||
updatedObjects = [
|
||||
...currentObjects,
|
||||
{
|
||||
object_name: objectName,
|
||||
location: location as [number, number, number],
|
||||
rotation: rotation as [number, number, number],
|
||||
scale: scale as [number, number, number],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
updateSceneConfiguration("objects", updatedObjects);
|
||||
|
||||
// Send direct update via WebSocket
|
||||
updateObject(objectName, location, rotation, scale);
|
||||
},
|
||||
[sceneConfiguration, updateSceneConfiguration, updateObject]
|
||||
);
|
||||
|
||||
return {
|
||||
// Original store methods
|
||||
sceneConfiguration,
|
||||
updateSceneConfiguration,
|
||||
removeSceneConfiguration,
|
||||
setSceneConfiguration,
|
||||
resetSceneConfiguration,
|
||||
|
||||
// Direct control methods
|
||||
updateCamera: handleUpdateCamera,
|
||||
updateLight: handleUpdateLight,
|
||||
updateMaterial: handleUpdateMaterial,
|
||||
updateObject: handleUpdateObject,
|
||||
};
|
||||
}
|
100
frontend/lib/handlers/websocketMessageHandler.ts
Normal file
100
frontend/lib/handlers/websocketMessageHandler.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
AnimationResponseMessage,
|
||||
WebSocketMessage,
|
||||
} from "@/lib/types/websocket";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Process WebSocket messages and route them to appropriate handlers
|
||||
*/
|
||||
export function processWebSocketMessage(
|
||||
data: WebSocketMessage,
|
||||
handlers: {
|
||||
onAnimationResponse?: (data: AnimationResponseMessage) => void;
|
||||
onTemplateControls?: (data: any) => void;
|
||||
onAssetOperationResponse?: (data: any) => void;
|
||||
onPreviewFrame?: (data: any) => void;
|
||||
onBroadcastComplete?: () => void;
|
||||
onError?: (message: string) => void;
|
||||
[key: string]: ((data: any) => void) | undefined;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
// Handle error messages
|
||||
if (data.status === "ERROR") {
|
||||
if (handlers.onError) {
|
||||
handlers.onError(data.message || "Unknown error");
|
||||
} else {
|
||||
toast.error(data.message || "Unknown error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle animation responses
|
||||
if (
|
||||
data.command === "camera_animation_result" ||
|
||||
data.command === "light_animation_result" ||
|
||||
data.command === "product_animation_result"
|
||||
) {
|
||||
if (handlers.onAnimationResponse) {
|
||||
handlers.onAnimationResponse(data as AnimationResponseMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle template controls response
|
||||
if (data.command === "template_controls_result") {
|
||||
if (handlers.onTemplateControls) {
|
||||
handlers.onTemplateControls(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle asset operation responses
|
||||
if (
|
||||
data.command?.endsWith("_result") &&
|
||||
[
|
||||
"append_asset_result",
|
||||
"remove_assets_result",
|
||||
"swap_assets_result",
|
||||
"rotate_assets_result",
|
||||
"scale_assets_result",
|
||||
"asset_info_result",
|
||||
].includes(data.command)
|
||||
) {
|
||||
if (handlers.onAssetOperationResponse) {
|
||||
handlers.onAssetOperationResponse(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle preview frames
|
||||
if (data.type === "frame") {
|
||||
if (handlers.onPreviewFrame) {
|
||||
handlers.onPreviewFrame(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle broadcast complete
|
||||
if (data.type === "broadcast_complete") {
|
||||
if (handlers.onBroadcastComplete) {
|
||||
handlers.onBroadcastComplete();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle custom handlers
|
||||
const customHandler = handlers[`on${data.command}`];
|
||||
if (customHandler) {
|
||||
customHandler(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Log unhandled messages
|
||||
console.log("Unhandled WebSocket message:", data);
|
||||
} catch (error) {
|
||||
console.error("Error processing WebSocket message:", error);
|
||||
toast.error("Error processing message from server");
|
||||
}
|
||||
}
|
55
frontend/lib/services/animationService.ts
Normal file
55
frontend/lib/services/animationService.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Animation } from "../types/animations";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1";
|
||||
|
||||
/**
|
||||
* Fetch animations from the server
|
||||
* @param type Optional animation type filter: "camera", "light", or "product"
|
||||
* @returns Promise with array of animations
|
||||
*/
|
||||
export const fetchAnimations = async (
|
||||
type?: "camera" | "light" | "product"
|
||||
): Promise<Animation[]> => {
|
||||
try {
|
||||
const url = new URL(`${API_URL}/templates/animations`);
|
||||
|
||||
if (type) {
|
||||
url.searchParams.append("animation_type", type);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch animations: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching animations:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a specific animation by ID
|
||||
* @param id Animation ID
|
||||
* @returns Promise with animation data
|
||||
*/
|
||||
export const fetchAnimationById = async (
|
||||
id: string
|
||||
): Promise<Animation | null> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/templates/animations/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch animation: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching animation ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
22
frontend/lib/types/animations.ts
Normal file
22
frontend/lib/types/animations.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface Animation {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
template_type: "camera" | "light" | "product_animation";
|
||||
templateData: any;
|
||||
is_public: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
creator_id: string;
|
||||
}
|
||||
|
||||
export interface AnimationCategory {
|
||||
name: string;
|
||||
animations: Animation[];
|
||||
}
|
||||
|
||||
export interface AnimationResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
@@ -29,6 +29,7 @@ export interface AssetPlacerState {
|
||||
isAssetPlaced: (assetId: string) => boolean;
|
||||
getPlacedAssetByEmptyName: (emptyName: string) => PlacedAsset | undefined;
|
||||
getEmptyNameForAsset: (assetId: string) => string | undefined;
|
||||
clearPlacedAssets: () => void;
|
||||
}
|
||||
|
||||
// Initial static assets (to be replaced with API call later)
|
||||
|
@@ -8,26 +8,34 @@ type SupportedControl =
|
||||
| "rotation"
|
||||
| "scale";
|
||||
|
||||
interface Camera {
|
||||
name: string;
|
||||
interface BaseControl {
|
||||
id: string; // original name with prefix
|
||||
name: string; // original name with prefix (for backward compatibility)
|
||||
displayName: string; // name without prefix for UI display
|
||||
supported_controls: SupportedControl[];
|
||||
}
|
||||
|
||||
interface Light {
|
||||
name: string;
|
||||
type: "AREA";
|
||||
supported_controls: SupportedControl[];
|
||||
interface Camera extends BaseControl {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface ControllableObject {
|
||||
name: string;
|
||||
type: "LIGHT" | "CAMERA" | "EMPTY";
|
||||
supported_controls: SupportedControl[];
|
||||
interface Light extends BaseControl {
|
||||
type: string;
|
||||
light_type: string;
|
||||
}
|
||||
|
||||
interface Material extends BaseControl {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface ControllableObject extends BaseControl {
|
||||
type: string;
|
||||
object_type: string;
|
||||
}
|
||||
|
||||
export interface TemplateControls {
|
||||
cameras: Camera[];
|
||||
lights: Light[];
|
||||
materials: any[]; // Expanded this as needed
|
||||
materials: Material[];
|
||||
objects: ControllableObject[];
|
||||
}
|
||||
|
@@ -10,9 +10,11 @@ export interface WebSocketMessage {
|
||||
payload?: any;
|
||||
status?: string;
|
||||
message?: string;
|
||||
message_id?: string; // Added to support both message and message_id
|
||||
data?: any;
|
||||
params?: any;
|
||||
controllables?: any;
|
||||
recovery?: boolean; // Added for browser_ready recovery mode
|
||||
|
||||
// Asset Placer properties
|
||||
empty_name?: string;
|
||||
@@ -26,6 +28,23 @@ export interface WebSocketMessage {
|
||||
center_origin?: boolean;
|
||||
empty1_name?: string;
|
||||
empty2_name?: string;
|
||||
|
||||
// Scene control properties
|
||||
camera_name?: string;
|
||||
light_name?: string;
|
||||
material_name?: string;
|
||||
object_name?: string;
|
||||
color?: string | number[];
|
||||
strength?: number;
|
||||
roughness?: number;
|
||||
metallic?: number;
|
||||
location?: number[];
|
||||
rotation?: number[];
|
||||
scale?: number[];
|
||||
|
||||
// Animation properties
|
||||
animation_type?: "camera" | "light" | "product_animation";
|
||||
animation_id?: string;
|
||||
}
|
||||
|
||||
export interface WebSocketError {
|
||||
@@ -108,6 +127,28 @@ export interface AssetPlacerCommandMessage extends WebSocketMessage {
|
||||
| "get_asset_info";
|
||||
}
|
||||
|
||||
// Scene Control Message Types
|
||||
export interface SceneControlCommandMessage extends WebSocketMessage {
|
||||
command:
|
||||
| "update_camera"
|
||||
| "update_light"
|
||||
| "update_material"
|
||||
| "update_object";
|
||||
}
|
||||
|
||||
export interface SceneControlResponseMessage extends WebSocketMessage {
|
||||
command:
|
||||
| "update_camera_result"
|
||||
| "update_light_result"
|
||||
| "update_material_result"
|
||||
| "update_object_result";
|
||||
status: "success" | "failed";
|
||||
data: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AssetPlacerResponseMessage extends WebSocketMessage {
|
||||
command:
|
||||
| "append_asset_result"
|
||||
@@ -124,3 +165,25 @@ export interface AssetPlacerResponseMessage extends WebSocketMessage {
|
||||
assets?: any[];
|
||||
};
|
||||
}
|
||||
|
||||
// Animation Message Types
|
||||
export interface AnimationCommandMessage extends WebSocketMessage {
|
||||
command:
|
||||
| "load_camera_animation"
|
||||
| "load_light_animation"
|
||||
| "load_product_animation";
|
||||
animation_id: string;
|
||||
empty_name: string;
|
||||
}
|
||||
|
||||
export interface AnimationResponseMessage extends WebSocketMessage {
|
||||
command:
|
||||
| "camera_animation_result"
|
||||
| "light_animation_result"
|
||||
| "product_animation_result";
|
||||
status: "success" | "failed";
|
||||
data: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
94
frontend/store/animationStore.ts
Normal file
94
frontend/store/animationStore.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { Animation } from "@/lib/types/animations";
|
||||
import { fetchAnimations } from "@/lib/services/animationService";
|
||||
|
||||
interface AnimationState {
|
||||
animations: {
|
||||
camera: Animation[];
|
||||
light: Animation[];
|
||||
product: Animation[];
|
||||
};
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedAnimations: {
|
||||
camera: string | null;
|
||||
light: string | null;
|
||||
product: string | null;
|
||||
};
|
||||
|
||||
// Actions
|
||||
fetchAllAnimations: () => Promise<void>;
|
||||
selectAnimation: (type: "camera" | "light" | "product", id: string) => void;
|
||||
clearSelections: () => void;
|
||||
}
|
||||
|
||||
export const useAnimationStore = create<AnimationState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
animations: {
|
||||
camera: [],
|
||||
light: [],
|
||||
product: [],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedAnimations: {
|
||||
camera: null,
|
||||
light: null,
|
||||
product: null,
|
||||
},
|
||||
|
||||
fetchAllAnimations: async () => {
|
||||
// If already loading, don't trigger another fetch
|
||||
if (get().isLoading) return;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const [camera, light, product] = await Promise.all([
|
||||
fetchAnimations("camera"),
|
||||
fetchAnimations("light"),
|
||||
fetchAnimations("product"),
|
||||
]);
|
||||
|
||||
set({
|
||||
animations: { camera, light, product },
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
error: "Failed to fetch animations",
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
selectAnimation: (type, id) => {
|
||||
// Only update if the selection actually changes
|
||||
if (get().selectedAnimations[type] !== id) {
|
||||
set((state) => ({
|
||||
selectedAnimations: {
|
||||
...state.selectedAnimations,
|
||||
[type]: id,
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
clearSelections: () =>
|
||||
set({
|
||||
selectedAnimations: {
|
||||
camera: null,
|
||||
light: null,
|
||||
product: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "animation-storage",
|
||||
partialize: (state) => ({
|
||||
selectedAnimations: state.selectedAnimations,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
@@ -52,6 +52,8 @@ export const useAssetPlacerStore = create<AssetPlacerState>()(
|
||||
return get().placedAssets.find((asset) => asset.assetId === assetId)
|
||||
?.emptyName;
|
||||
},
|
||||
|
||||
clearPlacedAssets: () => set({ placedAssets: [] }),
|
||||
}),
|
||||
{
|
||||
name: "asset-placer-storage",
|
||||
|
@@ -33,6 +33,24 @@ export const useSceneConfigStore = create<SceneConfigStore>((set) => ({
|
||||
case "light":
|
||||
delete newConfig.lights;
|
||||
break;
|
||||
case "material":
|
||||
if (newConfig.materials && assetId) {
|
||||
newConfig.materials = newConfig.materials.filter(
|
||||
(m) => m.material_name !== assetId
|
||||
);
|
||||
} else {
|
||||
delete newConfig.materials;
|
||||
}
|
||||
break;
|
||||
case "object":
|
||||
if (newConfig.objects && assetId) {
|
||||
newConfig.objects = newConfig.objects.filter(
|
||||
(o) => o.object_name !== assetId
|
||||
);
|
||||
} else {
|
||||
delete newConfig.objects;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return { sceneConfiguration: newConfig };
|
||||
}),
|
||||
|
Reference in New Issue
Block a user