feat: Implemented Asset Placer and Animations system

This commit is contained in:
2025-03-09 14:13:45 +02:00
parent 760f45c3d3
commit 7e64d77c2c
81 changed files with 7668 additions and 1312 deletions

114
.clinerules Normal file
View 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"

View File

@@ -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__":

View 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

View 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

View 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

View File

@@ -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:

View 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

View File

@@ -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

View 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

View 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

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

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

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

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

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

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

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

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

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

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

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

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

View 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

View 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

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

View File

@@ -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()

View File

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

View File

@@ -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")

View File

@@ -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'
]

View File

@@ -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'
]

View File

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

View File

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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():

View File

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

View File

@@ -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
View 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.

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

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

View File

@@ -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"

View File

@@ -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(() => {

View File

@@ -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">

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

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

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

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

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

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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 />,
},
];

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

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

View File

@@ -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>
);

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

View File

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

View File

@@ -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[];
}

View File

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

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

View File

@@ -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",

View File

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