commit 08490b2051954c6544eb0908feec33c18736dc65 Author: Nick Pittas Date: Sat Jan 10 11:34:32 2026 +0200 Initial commit: ComfyUI Camera Angle Selector node diff --git a/.github/agents/bmd-custom-bmb-agent-builder.agent.md b/.github/agents/bmd-custom-bmb-agent-builder.agent.md new file mode 100644 index 0000000..478f3c1 --- /dev/null +++ b/.github/agents/bmd-custom-bmb-agent-builder.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Agent Builder agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Agent Builder Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/bmb/agents/agent-builder.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-bmb-module-builder.agent.md b/.github/agents/bmd-custom-bmb-module-builder.agent.md new file mode 100644 index 0000000..1064dde --- /dev/null +++ b/.github/agents/bmd-custom-bmb-module-builder.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Module Builder agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Module Builder Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/bmb/agents/module-builder.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-bmb-workflow-builder.agent.md b/.github/agents/bmd-custom-bmb-workflow-builder.agent.md new file mode 100644 index 0000000..6c00421 --- /dev/null +++ b/.github/agents/bmd-custom-bmb-workflow-builder.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Workflow Builder agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Workflow Builder Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/bmb/agents/workflow-builder.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-bmm-analyst.agent.md b/.github/agents/bmd-custom-bmm-analyst.agent.md new file mode 100644 index 0000000..06453a7 --- /dev/null +++ b/.github/agents/bmd-custom-bmm-analyst.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Analyst agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Analyst Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/bmm/agents/analyst.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-bmm-architect.agent.md b/.github/agents/bmd-custom-bmm-architect.agent.md new file mode 100644 index 0000000..8f4a8c1 --- /dev/null +++ b/.github/agents/bmd-custom-bmm-architect.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Architect agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Architect Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/bmm/agents/architect.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-bmm-dev.agent.md b/.github/agents/bmd-custom-bmm-dev.agent.md new file mode 100644 index 0000000..ea91fc7 --- /dev/null +++ b/.github/agents/bmd-custom-bmm-dev.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Dev agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Dev Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/bmm/agents/dev.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-bmm-pm.agent.md b/.github/agents/bmd-custom-bmm-pm.agent.md new file mode 100644 index 0000000..e2652e1 --- /dev/null +++ b/.github/agents/bmd-custom-bmm-pm.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Pm agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Pm Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/bmm/agents/pm.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-bmm-quick-flow-solo-dev.agent.md b/.github/agents/bmd-custom-bmm-quick-flow-solo-dev.agent.md new file mode 100644 index 0000000..82502c5 --- /dev/null +++ b/.github/agents/bmd-custom-bmm-quick-flow-solo-dev.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Quick Flow Solo Dev agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Quick Flow Solo Dev Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/bmm/agents/quick-flow-solo-dev.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-bmm-sm.agent.md b/.github/agents/bmd-custom-bmm-sm.agent.md new file mode 100644 index 0000000..49144b3 --- /dev/null +++ b/.github/agents/bmd-custom-bmm-sm.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Sm agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Sm Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/bmm/agents/sm.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-bmm-tea.agent.md b/.github/agents/bmd-custom-bmm-tea.agent.md new file mode 100644 index 0000000..e710b0a --- /dev/null +++ b/.github/agents/bmd-custom-bmm-tea.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Tea agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Tea Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/bmm/agents/tea.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-bmm-tech-writer.agent.md b/.github/agents/bmd-custom-bmm-tech-writer.agent.md new file mode 100644 index 0000000..74a593a --- /dev/null +++ b/.github/agents/bmd-custom-bmm-tech-writer.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Tech Writer agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Tech Writer Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/bmm/agents/tech-writer.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-bmm-ux-designer.agent.md b/.github/agents/bmd-custom-bmm-ux-designer.agent.md new file mode 100644 index 0000000..882f537 --- /dev/null +++ b/.github/agents/bmd-custom-bmm-ux-designer.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Ux Designer agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Ux Designer Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/bmm/agents/ux-designer.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-cis-brainstorming-coach.agent.md b/.github/agents/bmd-custom-cis-brainstorming-coach.agent.md new file mode 100644 index 0000000..bcadfb3 --- /dev/null +++ b/.github/agents/bmd-custom-cis-brainstorming-coach.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Brainstorming Coach agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Brainstorming Coach Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/cis/agents/brainstorming-coach.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-cis-creative-problem-solver.agent.md b/.github/agents/bmd-custom-cis-creative-problem-solver.agent.md new file mode 100644 index 0000000..97cceb1 --- /dev/null +++ b/.github/agents/bmd-custom-cis-creative-problem-solver.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Creative Problem Solver agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Creative Problem Solver Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/cis/agents/creative-problem-solver.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-cis-design-thinking-coach.agent.md b/.github/agents/bmd-custom-cis-design-thinking-coach.agent.md new file mode 100644 index 0000000..b1b86aa --- /dev/null +++ b/.github/agents/bmd-custom-cis-design-thinking-coach.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Design Thinking Coach agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Design Thinking Coach Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/cis/agents/design-thinking-coach.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-cis-innovation-strategist.agent.md b/.github/agents/bmd-custom-cis-innovation-strategist.agent.md new file mode 100644 index 0000000..ce77c70 --- /dev/null +++ b/.github/agents/bmd-custom-cis-innovation-strategist.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Innovation Strategist agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Innovation Strategist Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/cis/agents/innovation-strategist.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-cis-presentation-master.agent.md b/.github/agents/bmd-custom-cis-presentation-master.agent.md new file mode 100644 index 0000000..6666b0c --- /dev/null +++ b/.github/agents/bmd-custom-cis-presentation-master.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Presentation Master agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Presentation Master Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/cis/agents/presentation-master.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-cis-storyteller.agent.md b/.github/agents/bmd-custom-cis-storyteller.agent.md new file mode 100644 index 0000000..6a6f4fb --- /dev/null +++ b/.github/agents/bmd-custom-cis-storyteller.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Storyteller agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Storyteller Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/cis/agents/storyteller/storyteller.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/agents/bmd-custom-core-bmad-master.agent.md b/.github/agents/bmd-custom-core-bmad-master.agent.md new file mode 100644 index 0000000..12c3fff --- /dev/null +++ b/.github/agents/bmd-custom-core-bmad-master.agent.md @@ -0,0 +1,17 @@ +--- +description: "Activates the Bmad Master agent persona." +tools: ["changes","edit","fetch","githubRepo","problems","runCommands","runTasks","runTests","search","runSubagent","testFailure","todos","usages"] +--- + +# Bmad Master Agent + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + + +1. LOAD the FULL agent file from @_bmad/core/agents/bmad-master.md +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. Execute ALL activation steps exactly as written in the agent file +4. Follow the agent's persona and menu system precisely +5. Stay in character throughout the session + + diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6bf9293 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,82 @@ +# Copilot Instructions - Angles (ComfyUI Camera Angle Selector) + +## Project Overview + +This is a **ComfyUI custom node** that provides an interactive 3D interface for selecting camera angles. The main deliverable is in `ComfyUI_CameraAngleSelector/` - a Python + JavaScript node that renders a Three.js 3D viewport inline within ComfyUI. + +The `_bmad/` folder contains BMad Method tooling for project planning and documentation - it's NOT part of the shipped product. + +## Architecture + +### ComfyUI Node Structure +``` +ComfyUI_CameraAngleSelector/ +├── __init__.py # Node registration (WEB_DIRECTORY, NODE_CLASS_MAPPINGS) +├── camera_angle_selector.py # Python backend - legacy ComfyUI API +└── web/js/camera_angle_extension.js # Three.js frontend widget (~740 lines) +``` + +### Key Patterns + +**ComfyUI Extension Pattern** - The JavaScript uses `nodeCreated` callback to inject the 3D widget: +```javascript +app.registerExtension({ + name: "Comfy.CameraAngleSelector", + async nodeCreated(node) { + if (node.comfyClass !== "CameraAngleSelector") return; + // Create and attach 3D widget via node.addDOMWidget() + } +}); +``` + +**96 Camera Angle Combinations** - Generated from constants: +- `VIEW_DIRECTIONS` (8): front, front-right quarter, right, back-right quarter, back, back-left quarter, left, front-left quarter +- `HEIGHT_ANGLES` (4): low-angle shot, eye-level shot, elevated shot, high-angle shot +- `SHOT_SIZES` (3): close-up, medium shot, wide shot + +**Output Format**: ` {direction} {height} {size}` (e.g., ` front view low-angle shot close-up`) + +### Frontend Widget Pattern + +The JavaScript extension in `web/js/camera_angle_extension.js`: +- Loads Three.js dynamically from CDN (`r128`) +- Uses `CameraAngleWidget` class with inline rendering +- Communicates via `selected_indices` JSON string parameter +- Color-coded cameras: height by body color, size by cone/indicator color + +## Development Workflow + +### Testing in ComfyUI +1. Copy/symlink `ComfyUI_CameraAngleSelector/` to ComfyUI's `custom_nodes/` directory +2. Restart ComfyUI server +3. Node appears as "Camera Angle Selector" in camera category +4. Browser DevTools console shows any JavaScript errors + +### Key Files to Modify + +| Task | File | +|------|------| +| Add new angle types | `camera_angle_selector.py` - modify constants | +| Change 3D visuals | `web/js/camera_angle_extension.js` - Three.js scene code | +| Modify output format | `camera_angle_selector.py` - `execute()` method | +| Add UI controls | `camera_angle_extension.js` - `CameraAngleWidget` class | + +## Conventions + +- **Prompt prefix**: All outputs use `` prefix (standard for camera angle prompts) +- **Color scheme**: Dark theme matching ComfyUI (`#1a1a2e` background, `#0f3460` accents) +- **Widget sizing**: Minimum 240px height for 3D viewport +- **JSON state**: Selection state stored as JSON array of indices `"[0, 5, 12]"` + +## BMad Tooling (Non-Product) + +The `_bmad/` directory contains planning framework - ignore when working on the node itself. Relevant outputs: +- `_bmad-output/planning-artifacts/prd.md` - Product requirements (reference only) +- `_bmad-output/planning-artifacts/product-brief-*.md` - Vision document + +## Testing Notes + +- No automated test suite currently exists +- Manual testing: verify 3D scene loads, click selection works, output connects to downstream nodes +- Check browser console for Three.js or ComfyUI extension errors +- Test both ComfyUI v1.0+ and legacy versions for API compatibility diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa96edd --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE and editor configs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.pydevproject +.settings/ + +# AI agent configs +.claude/ +.opencode/ +.roo/ +.cursor/ +.windsurf/ +.kilocodemodes + +# OS files +.DS_Store +Thumbs.db +Desktop.ini + +# Temporary files +*.tmp +*.temp +*.log + +# Screenshots in root (keep the one in ComfyUI_CameraAngleSelector/) +/Screenshot*.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a41af4 --- /dev/null +++ b/README.md @@ -0,0 +1,202 @@ +# ComfyUI Camera Angle Selector + +A ComfyUI custom node that provides an interactive 3D interface for selecting camera angles. Select from 96 different camera angle combinations (8 view directions × 4 height angles × 3 shot sizes) with visual feedback and multi-selection support. + +![Camera Angle Selector Screenshot](screenshot.png) + +## Features + +- **3D Visualization**: Interactive 3D scene showing camera positions around a central subject +- **Multi-Selection**: Select multiple camera angles simultaneously +- **Color-Coded Cameras**: Direction-based colors (green=front, red=back) with height indicator rings +- **Three Shot Size Layers**: Close-up (inner), Medium (middle), Wide (outer) rings +- **Filter Controls**: Filter by view direction, height angle, and shot size +- **Drag to Rotate**: Click and drag to rotate the 3D scene +- **Zoom**: Mouse wheel to zoom in/out +- **Resizable**: Node scales with 1:1 aspect ratio 3D viewport +- **Selection List**: View and manage selected angles with individual removal +- **List Output**: Returns a list of formatted prompt strings + +## Camera Angles + +### View Directions (8 angles) +- Front view +- Front-right quarter view +- Right side view +- Back-right quarter view +- Back view +- Back-left quarter view +- Left side view +- Front-left quarter view + +### Height Angles (4 types) +- Low-angle shot +- Eye-level shot +- Elevated shot +- High-angle shot + +### Shot Sizes (3 types) +- Close-up +- Medium shot +- Wide shot + +**Total: 96 unique camera angle combinations** + +## Installation + +### Prerequisites +- ComfyUI installed and running +- Python 3.8 or higher + +### Step-by-Step Installation + +1. **Navigate to ComfyUI's custom_nodes directory:** + ```bash + cd ComfyUI/custom_nodes + ``` + +2. **Clone or copy this repository:** + ```bash + git clone ComfyUI_CameraAngleSelector + ``` + + Or if you have the files locally: + ```bash + cp -r /path/to/ComfyUI_CameraAngleSelector . + ``` + +3. **Restart ComfyUI** to load the new node. + +## Usage + +### In ComfyUI + +1. **Add the Node:** + - Right-click in the node editor + - Search for "Camera Angle Selector" + - Add the node to your workflow + +2. **Select Camera Angles:** + - Click on the 3D visualization to open the full interface + - Click on camera icons to select/deselect angles + - Drag the scene to rotate the view + - Use filters to narrow down visible cameras + - Use "Select Visible" to select all currently filtered cameras + - Use "Clear All" to deselect all cameras + +3. **Connect the Output:** + - Connect the `selected_angles` output to any node that accepts string inputs + - The output is a list of strings, one per selected camera angle + +### Output Format + +Each selected camera angle is returned as a formatted string: + +``` + {view_direction} {height_angle} {shot_size} +``` + +**Example outputs:** +``` + front view low-angle shot close-up + front-right quarter view eye-level shot medium shot + back view high-angle shot wide shot +``` + +## Node Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `selected_indices` | STRING | Internal parameter (auto-managed by the 3D interface) | + +## Node Outputs + +| Output | Type | Description | +|--------|------|-------------| +| `selected_angles` | STRING (List) | List of selected camera angle prompt strings | + +## Interface Controls + +### 3D View +- **Click** on a camera to select/deselect it +- **Drag** to rotate the view around the subject +- **Hover** over cameras to see their details + +### Filters +- **View Direction**: Filter by front, side, or back views +- **Height Angle**: Filter by low, eye-level, elevated, or high angles +- **Shot Size**: Filter by close-up, medium, or wide shots + +### Action Buttons +- **Clear All**: Deselect all cameras +- **Select Visible**: Select all currently visible (filtered) cameras + +### Legend + +**Camera Body Colors (Direction):** +- **Green**: Front view +- **Light Green**: Front quarter views +- **Yellow**: Side views (left/right) +- **Orange**: Back quarter views +- **Red**: Back view + +**Shot Size Layers (Distance from subject):** +- **Inner ring**: Close-up shots +- **Middle ring**: Medium shots +- **Outer ring**: Wide shots + +**Height Indicator Ring (on top of camera):** +- **Red**: Low-angle +- **Blue**: Eye-level +- **Purple**: Elevated +- **Teal**: High-angle + +## File Structure + +``` +ComfyUI_CameraAngleSelector/ +├── __init__.py # Node registration +├── camera_angle_selector.py # Main Python node class +├── screenshot.png # Screenshot for README +├── web/ +│ └── js/ +│ └── camera_angle_extension.js # Three.js ComfyUI extension +└── README.md # This file +``` + +## Technical Details + +### Python Node +- Uses `OUTPUT_IS_LIST = (True,)` to enable list output +- Returns `("STRING",)` as `RETURN_TYPES` +- Handles JSON input from the frontend interface + +### Frontend +- Built with Three.js for 3D rendering +- Uses raycasting for click detection on 3D objects +- Implements drag-to-rotate camera controls +- PostMessage API for ComfyUI integration + +## Troubleshooting + +### Node not appearing in ComfyUI +- Ensure the folder is in `ComfyUI/custom_nodes/` +- Restart ComfyUI after installation +- Check for errors in the ComfyUI console + +### 3D interface not loading +- Check browser console for JavaScript errors +- Ensure Three.js CDN is accessible +- Try refreshing the page + +### Selections not persisting +- Make sure you're clicking directly on camera icons +- Check that the node is properly connected in the workflow + +## License + +This project is provided as-is for use with ComfyUI. + +## Credits + +Built for ComfyUI to provide an intuitive 3D interface for camera angle selection in AI image generation workflows. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..97473e3 --- /dev/null +++ b/__init__.py @@ -0,0 +1,13 @@ +from .camera_angle_selector import CameraAngleSelector + +WEB_DIRECTORY = "web" + +NODE_CLASS_MAPPINGS = { + "CameraAngleSelector": CameraAngleSelector, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "CameraAngleSelector": "Camera Angle Selector", +} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/camera_angle_selector.py b/camera_angle_selector.py new file mode 100644 index 0000000..6557275 --- /dev/null +++ b/camera_angle_selector.py @@ -0,0 +1,112 @@ +""" +Camera Angle Selector Node for ComfyUI +A custom node that provides a 3D visual interface for selecting camera angles. +The 3D visualization renders directly inside the node using Three.js. +""" + +import json + +# View directions (8 angles) +VIEW_DIRECTIONS = [ + "front view", + "front-right quarter view", + "right side view", + "back-right quarter view", + "back view", + "back-left quarter view", + "left side view", + "front-left quarter view", +] + +# Height angles (4 types) +HEIGHT_ANGLES = [ + "low-angle shot", + "eye-level shot", + "elevated shot", + "high-angle shot", +] + +# Shot sizes (3 types) +SHOT_SIZES = [ + "close-up", + "medium shot", + "wide shot", +] + +# Generate all 96 combinations +CAMERA_ANGLES = [] +for direction in VIEW_DIRECTIONS: + for height in HEIGHT_ANGLES: + for size in SHOT_SIZES: + CAMERA_ANGLES.append({ + "direction": direction, + "height": height, + "size": size, + "prompt": f" {direction} {height} {size}" + }) + + +class CameraAngleSelector: + """ + ComfyUI custom node for selecting camera angles using a 3D visual interface. + The 3D visualization is rendered by the companion JavaScript extension. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "selected_indices": ("STRING", { + "default": "[]", + "multiline": False, + }), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("selected_angles",) + OUTPUT_IS_LIST = (True,) + FUNCTION = "execute" + CATEGORY = "camera" + DESCRIPTION = "Select camera angles using a 3D visual interface rendered directly in the node" + + def execute(self, selected_indices="[]"): + """ + Execute the node and return the list of selected prompts. + + Args: + selected_indices: JSON string containing list of selected indices + + Returns: + Tuple containing list of selected angle prompts + """ + # Parse selected indices + try: + indices = json.loads(selected_indices) + except (json.JSONDecodeError, TypeError): + indices = [] + + if not isinstance(indices, list): + indices = [] + + # Clamp indices to valid range + clamped_indices = [] + for idx in indices: + if isinstance(idx, int): + clamped_indices.append(max(0, min(idx, len(CAMERA_ANGLES) - 1))) + + # Build ... prompts from clamped indices + selected_prompts = [] + for idx in clamped_indices: + selected_prompts.append(CAMERA_ANGLES[idx]["prompt"]) + + return (selected_prompts,) + + +NODE_CLASS_MAPPINGS = { + "CameraAngleSelector": CameraAngleSelector, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "CameraAngleSelector": "Camera Angle Selector", +} diff --git a/web/js/camera_angle_extension.js b/web/js/camera_angle_extension.js new file mode 100644 index 0000000..1983576 --- /dev/null +++ b/web/js/camera_angle_extension.js @@ -0,0 +1,1152 @@ +/** + * ComfyUI Extension - Camera Angle Selector + * + * This extension adds a 3D camera angle selector that renders directly + * inside the CameraAngleSelector node using Three.js. + * + * Features: + * - 3D visualization of 96 camera angles + * - Filter by direction (front, back, left, right, quarters) + * - Filter by shot size (close-up, medium, wide) + * - Filter by height (low, eye-level, elevated, high) + * - Select All / Clear All buttons + * - Selected angles list display + */ + +import { app } from "../../../scripts/app.js"; + +// Load Three.js dynamically +const THREE_URL = "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"; + +// Store Three.js reference +let THREE = null; +let threeLoadPromise = null; + +// Load Three.js dynamically (singleton pattern) +function loadThreeJS() { + if (threeLoadPromise) { + return threeLoadPromise; + } + + threeLoadPromise = new Promise((resolve, reject) => { + if (window.THREE) { + THREE = window.THREE; + resolve(THREE); + return; + } + + const script = document.createElement('script'); + script.src = THREE_URL; + script.onload = () => { + THREE = window.THREE; + resolve(THREE); + }; + script.onerror = () => { + threeLoadPromise = null; + reject(new Error("Failed to load Three.js")); + }; + document.head.appendChild(script); + }); + + return threeLoadPromise; +} + +// Camera angle data - must match Python backend +const VIEW_DIRECTIONS = [ + "front view", + "front-right quarter view", + "right side view", + "back-right quarter view", + "back view", + "back-left quarter view", + "left side view", + "front-left quarter view", +]; + +const HEIGHT_ANGLES = [ + "low-angle shot", + "eye-level shot", + "elevated shot", + "high-angle shot", +]; + +const SHOT_SIZES = [ + "close-up", + "medium shot", + "wide shot", +]; + +// Direction groups for filtering +const DIRECTION_GROUPS = { + 'front': [0], // front view + 'back': [4], // back view + 'left': [6], // left side view + 'right': [2], // right side view + 'front-q': [1, 7], // front-right quarter, front-left quarter + 'back-q': [3, 5], // back-right quarter, back-left quarter +}; + +// Generate all 96 combinations +function generateCameraAngles() { + const angles = []; + for (const direction of VIEW_DIRECTIONS) { + for (const height of HEIGHT_ANGLES) { + for (const size of SHOT_SIZES) { + angles.push({ + direction, + height, + size, + prompt: ` ${direction} ${height} ${size}` + }); + } + } + } + return angles; +} + +const CAMERA_ANGLES = generateCameraAngles(); + +// Color schemes - different colors for front vs back facing +const DIRECTION_COLORS = { + front: 0x4CAF50, // Green for front + frontQuarter: 0x8BC34A, // Light green for front quarters + side: 0xFFEB3B, // Yellow for sides + backQuarter: 0xFF9800, // Orange for back quarters + back: 0xF44336, // Red for back +}; + +// Map direction index to color +function getDirectionColor(dirIdx) { + switch(dirIdx) { + case 0: return DIRECTION_COLORS.front; // front + case 1: return DIRECTION_COLORS.frontQuarter; // front-right + case 7: return DIRECTION_COLORS.frontQuarter; // front-left + case 2: return DIRECTION_COLORS.side; // right + case 6: return DIRECTION_COLORS.side; // left + case 3: return DIRECTION_COLORS.backQuarter; // back-right + case 5: return DIRECTION_COLORS.backQuarter; // back-left + case 4: return DIRECTION_COLORS.back; // back + default: return 0x888888; + } +} + +// Height indicator colors (for the cone) +const HEIGHT_COLORS = [ + 0xe74c3c, // low-angle - red + 0x3498db, // eye-level - blue + 0x9b59b6, // elevated - purple + 0x1abc9c, // high-angle - teal +]; + +const SELECTED_COLOR = 0xe94560; + +/** + * Create the 3D Camera Angle Widget for a node + */ +class CameraAngle3DWidget { + constructor(node) { + this.node = node; + this.selectedIndices = new Set(); + + // Three.js references + this.scene = null; + this.camera = null; + this.renderer = null; + this.cameraMeshes = []; + this.resizeObserver = null; + this.animationId = null; + + // DOM elements + this.container = null; + this.canvas = null; + this.controlsPanel = null; + this.selectionList = null; + this.tooltip = null; + + // Filter state + this.activeFilters = { + directions: new Set(), // empty = all + heights: new Set(), + sizes: new Set(), + }; + + // Mouse state + this.isDragging = false; + this.previousMousePosition = { x: 0, y: 0 }; + this.dragDistance = 0; + this.mouse = null; + this.raycaster = null; + + // Layout - will be updated dynamically + this.minSize = 300; + this.viewportArea = null; + } + + async initialize() { + try { + await loadThreeJS(); + } catch (e) { + console.error("CameraAngleSelector: Failed to load Three.js:", e); + return false; + } + + this.createContainer(); + this.initThreeJS(); + this.createSubject(); + this.createCameraMarkers(); + this.setupEventListeners(); + this.startAnimation(); + + // Load initial value from widget + this.loadFromWidget(); + + return true; + } + + createContainer() { + // Create main container + this.container = document.createElement('div'); + this.container.className = 'camera-angle-3d-container'; + this.container.style.cssText = ` + width: 100%; + height: 100%; + position: relative; + overflow: hidden; + background: #1a1a2e; + border-radius: 4px; + border: 1px solid #0f3460; + box-sizing: border-box; + display: flex; + flex-direction: column; + `; + + // Create 3D viewport area - will maintain 1:1 aspect ratio + this.viewportArea = document.createElement('div'); + this.viewportArea.style.cssText = ` + width: 100%; + aspect-ratio: 1 / 1; + position: relative; + flex-shrink: 0; + min-height: 200px; + `; + + // Create canvas + this.canvas = document.createElement('canvas'); + this.canvas.style.cssText = ` + width: 100%; + height: 100%; + display: block; + cursor: grab; + outline: none; + `; + this.viewportArea.appendChild(this.canvas); + + // Create tooltip + this.tooltip = document.createElement('div'); + this.tooltip.style.cssText = ` + position: absolute; + background: rgba(22, 33, 62, 0.95); + color: #fff; + padding: 6px 10px; + border-radius: 4px; + font-size: 11px; + pointer-events: none; + display: none; + z-index: 100; + border: 1px solid #0f3460; + white-space: nowrap; + font-family: sans-serif; + `; + this.viewportArea.appendChild(this.tooltip); + + this.container.appendChild(this.viewportArea); + + // Create controls panel + this.createControlsPanel(); + + // Create selection list + this.createSelectionList(); + } + + createControlsPanel() { + this.controlsPanel = document.createElement('div'); + this.controlsPanel.style.cssText = ` + padding: 8px; + background: #16213e; + border-top: 1px solid #0f3460; + font-family: sans-serif; + font-size: 10px; + `; + + // Direction filters row + const dirRow = document.createElement('div'); + dirRow.style.cssText = 'margin-bottom: 6px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center;'; + dirRow.innerHTML = 'DIR:'; + + const dirButtons = [ + { label: 'Front', key: 'front', color: '#4CAF50' }, + { label: 'F-Qtr', key: 'front-q', color: '#8BC34A' }, + { label: 'Side', key: 'side', color: '#FFEB3B', textColor: '#000' }, + { label: 'B-Qtr', key: 'back-q', color: '#FF9800' }, + { label: 'Back', key: 'back', color: '#F44336' }, + ]; + + dirButtons.forEach(btn => { + const button = this.createFilterButton(btn.label, 'directions', btn.key, btn.color, btn.textColor); + dirRow.appendChild(button); + }); + + this.controlsPanel.appendChild(dirRow); + + // Shot size filters row + const sizeRow = document.createElement('div'); + sizeRow.style.cssText = 'margin-bottom: 6px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center;'; + sizeRow.innerHTML = 'SIZE:'; + + const sizeButtons = [ + { label: 'Close', key: 0, color: '#4a90d9' }, + { label: 'Medium', key: 1, color: '#50c878' }, + { label: 'Wide', key: 2, color: '#f39c12' }, + ]; + + sizeButtons.forEach(btn => { + const button = this.createFilterButton(btn.label, 'sizes', btn.key, btn.color); + sizeRow.appendChild(button); + }); + + this.controlsPanel.appendChild(sizeRow); + + // Height filters row + const heightRow = document.createElement('div'); + heightRow.style.cssText = 'margin-bottom: 8px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center;'; + heightRow.innerHTML = 'HGT:'; + + const heightButtons = [ + { label: 'Low', key: 0, color: '#e74c3c' }, + { label: 'Eye', key: 1, color: '#3498db' }, + { label: 'Elev', key: 2, color: '#9b59b6' }, + { label: 'High', key: 3, color: '#1abc9c' }, + ]; + + heightButtons.forEach(btn => { + const button = this.createFilterButton(btn.label, 'heights', btn.key, btn.color); + heightRow.appendChild(button); + }); + + this.controlsPanel.appendChild(heightRow); + + // Action buttons row + const actionRow = document.createElement('div'); + actionRow.style.cssText = 'display: flex; gap: 6px; justify-content: flex-start;'; + + const selectVisibleBtn = this.createActionButton('Select Filtered', () => this.selectFiltered()); + const selectAllBtn = this.createActionButton('Select All', () => this.selectAll()); + const clearAllBtn = this.createActionButton('Clear All', () => this.clearAll(), '#e74c3c'); + + actionRow.appendChild(selectVisibleBtn); + actionRow.appendChild(selectAllBtn); + actionRow.appendChild(clearAllBtn); + + this.controlsPanel.appendChild(actionRow); + + // Add legend + const legendRow = document.createElement('div'); + legendRow.style.cssText = 'margin-top: 8px; padding-top: 6px; border-top: 1px solid #0f3460; display: flex; gap: 12px; flex-wrap: wrap; font-size: 9px; color: #888;'; + legendRow.innerHTML = ` + ● Close (inner) + ● Medium (middle) + ● Wide (outer) + ■ Front ■ Back + `; + this.controlsPanel.appendChild(legendRow); + + this.container.appendChild(this.controlsPanel); + } + + createFilterButton(label, filterType, filterKey, bgColor, textColor = '#fff') { + const button = document.createElement('button'); + button.textContent = label; + button.dataset.filterType = filterType; + button.dataset.filterKey = filterKey; + button.style.cssText = ` + padding: 3px 8px; + border: 2px solid ${bgColor}; + background: transparent; + color: ${bgColor}; + border-radius: 3px; + cursor: pointer; + font-size: 10px; + font-weight: bold; + transition: all 0.2s; + `; + + button.addEventListener('click', () => { + const isActive = this.activeFilters[filterType].has(filterKey); + if (isActive) { + this.activeFilters[filterType].delete(filterKey); + button.style.background = 'transparent'; + button.style.color = bgColor; + } else { + this.activeFilters[filterType].add(filterKey); + button.style.background = bgColor; + button.style.color = textColor; + } + this.updateCameraVisibility(); + }); + + return button; + } + + createActionButton(label, onClick, bgColor = '#0f3460') { + const button = document.createElement('button'); + button.textContent = label; + button.style.cssText = ` + padding: 4px 10px; + border: none; + background: ${bgColor}; + color: #fff; + border-radius: 3px; + cursor: pointer; + font-size: 10px; + font-weight: bold; + transition: opacity 0.2s; + `; + button.addEventListener('mouseenter', () => button.style.opacity = '0.8'); + button.addEventListener('mouseleave', () => button.style.opacity = '1'); + button.addEventListener('click', onClick); + return button; + } + + createSelectionList() { + const listContainer = document.createElement('div'); + listContainer.style.cssText = ` + flex: 1; + overflow-y: auto; + background: #0d1b2a; + border-top: 1px solid #0f3460; + padding: 6px; + min-height: 80px; + `; + + const header = document.createElement('div'); + header.style.cssText = ` + color: #888; + font-size: 10px; + margin-bottom: 4px; + font-family: sans-serif; + display: flex; + justify-content: space-between; + `; + header.innerHTML = 'Selected Angles:0'; + listContainer.appendChild(header); + + this.selectionList = document.createElement('div'); + this.selectionList.style.cssText = ` + font-family: monospace; + font-size: 9px; + color: #ccc; + line-height: 1.6; + `; + listContainer.appendChild(this.selectionList); + + this.container.appendChild(listContainer); + } + + updateSelectionList() { + const sortedIndices = Array.from(this.selectedIndices).sort((a, b) => a - b); + + // Update count + const countEl = document.getElementById('selection-count-' + this.node.id); + if (countEl) countEl.textContent = sortedIndices.length; + + // Build list items + this.selectionList.innerHTML = ''; + + sortedIndices.forEach(idx => { + const angle = CAMERA_ANGLES[idx]; + const item = document.createElement('div'); + item.style.cssText = ` + display: flex; + justify-content: space-between; + align-items: center; + padding: 2px 4px; + background: rgba(15, 52, 96, 0.5); + border-radius: 2px; + margin-bottom: 2px; + `; + + const text = document.createElement('span'); + text.textContent = `${angle.direction} | ${angle.height} | ${angle.size}`; + text.style.cssText = 'overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;'; + + const removeBtn = document.createElement('button'); + removeBtn.textContent = '×'; + removeBtn.style.cssText = ` + background: none; + border: none; + color: #e94560; + cursor: pointer; + font-size: 14px; + padding: 0 4px; + line-height: 1; + `; + removeBtn.addEventListener('click', () => this.deselectByIndex(idx)); + + item.appendChild(text); + item.appendChild(removeBtn); + this.selectionList.appendChild(item); + }); + + if (sortedIndices.length === 0) { + const empty = document.createElement('div'); + empty.textContent = 'Click cameras in the 3D view to select angles'; + empty.style.cssText = 'color: #555; font-style: italic;'; + this.selectionList.appendChild(empty); + } + } + + deselectByIndex(idx) { + this.selectedIndices.delete(idx); + const group = this.cameraMeshes[idx]; + if (group) { + this.setSelected(group, false); + } + this.updateSelectionList(); + this.syncToWidget(); + } + + updateCameraVisibility() { + this.cameraMeshes.forEach((group, idx) => { + const { dirIdx, heightIdx, sizeIdx } = group.userData; + + let visible = true; + + // Check direction filter + if (this.activeFilters.directions.size > 0) { + let dirMatch = false; + for (const dirKey of this.activeFilters.directions) { + if (dirKey === 'side') { + if (dirIdx === 2 || dirIdx === 6) dirMatch = true; + } else { + const indices = DIRECTION_GROUPS[dirKey] || []; + if (indices.includes(dirIdx)) dirMatch = true; + } + } + visible = visible && dirMatch; + } + + // Check height filter + if (this.activeFilters.heights.size > 0) { + visible = visible && this.activeFilters.heights.has(heightIdx); + } + + // Check size filter + if (this.activeFilters.sizes.size > 0) { + visible = visible && this.activeFilters.sizes.has(sizeIdx); + } + + group.visible = visible; + }); + } + + selectFiltered() { + this.cameraMeshes.forEach((group, idx) => { + if (group.visible && !this.selectedIndices.has(idx)) { + this.selectedIndices.add(idx); + this.setSelected(group, true); + } + }); + this.updateSelectionList(); + this.syncToWidget(); + } + + selectAll() { + this.cameraMeshes.forEach((group, idx) => { + if (!this.selectedIndices.has(idx)) { + this.selectedIndices.add(idx); + this.setSelected(group, true); + } + }); + this.updateSelectionList(); + this.syncToWidget(); + } + + clearAll() { + this.cameraMeshes.forEach((group, idx) => { + if (this.selectedIndices.has(idx)) { + this.selectedIndices.delete(idx); + this.setSelected(group, false); + } + }); + this.updateSelectionList(); + this.syncToWidget(); + } + + initThreeJS() { + // Scene + this.scene = new THREE.Scene(); + this.scene.background = new THREE.Color(0x1a1a2e); + + // Camera - positioned to see all three layers of camera markers + // 1:1 aspect ratio for the viewport + this.camera = new THREE.PerspectiveCamera(60, 1, 0.1, 1000); + this.camera.position.set(0, 3, 10); + this.camera.lookAt(0, 0, 0); + + // Renderer - size will be set by resize observer + this.renderer = new THREE.WebGLRenderer({ + canvas: this.canvas, + antialias: true + }); + const size = this.viewportArea.clientWidth || 300; + this.renderer.setSize(size, size); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + + // Lights + const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); + this.scene.add(ambientLight); + + const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); + directionalLight.position.set(5, 10, 7); + this.scene.add(directionalLight); + + const backLight = new THREE.DirectionalLight(0xffffff, 0.3); + backLight.position.set(-5, 5, -7); + this.scene.add(backLight); + + // Grid + const gridHelper = new THREE.GridHelper(10, 20, 0x0f3460, 0x16213e); + gridHelper.position.y = -2; + this.scene.add(gridHelper); + + // Raycaster for click detection + this.raycaster = new THREE.Raycaster(); + this.mouse = new THREE.Vector2(); + } + + createSubject() { + const group = new THREE.Group(); + group.name = 'subject'; + + // Head + const headGeometry = new THREE.SphereGeometry(0.4, 32, 32); + const headMaterial = new THREE.MeshPhongMaterial({ + color: 0x0f3460, + emissive: 0x0a1f3d, + shininess: 30 + }); + const head = new THREE.Mesh(headGeometry, headMaterial); + head.position.y = 1.2; + group.add(head); + + // Face indicator (nose) - shows which way is "front" + const noseGeometry = new THREE.ConeGeometry(0.1, 0.2, 8); + const noseMaterial = new THREE.MeshPhongMaterial({ + color: 0x4CAF50, // Green to match front cameras + emissive: 0x2E7D32 + }); + const nose = new THREE.Mesh(noseGeometry, noseMaterial); + nose.rotation.x = -Math.PI / 2; + nose.position.set(0, 1.2, 0.45); + group.add(nose); + + // Torso + const torsoGeometry = new THREE.CylinderGeometry(0.3, 0.35, 1.0, 32); + const torsoMaterial = new THREE.MeshPhongMaterial({ + color: 0x16213e, + emissive: 0x0a1f3d, + shininess: 30 + }); + const torso = new THREE.Mesh(torsoGeometry, torsoMaterial); + torso.position.y = 0.3; + group.add(torso); + + // Shoulders + const shoulderGeometry = new THREE.BoxGeometry(1.2, 0.15, 0.3); + const shoulderMaterial = new THREE.MeshPhongMaterial({ + color: 0x1a2a4a, + emissive: 0x0a1f3d, + shininess: 30 + }); + const shoulders = new THREE.Mesh(shoulderGeometry, shoulderMaterial); + shoulders.position.y = 0.8; + group.add(shoulders); + + // Base + const baseGeometry = new THREE.CylinderGeometry(0.5, 0.6, 0.1, 32); + const baseMaterial = new THREE.MeshPhongMaterial({ + color: 0x0f3460, + emissive: 0x051020 + }); + const base = new THREE.Mesh(baseGeometry, baseMaterial); + base.position.y = -0.25; + group.add(base); + + // Front/Back labels on base + this.createTextLabel(group, 'F', 0, -0.19, 0.4, 0x4CAF50); + this.createTextLabel(group, 'B', 0, -0.19, -0.4, 0xF44336); + + // Orbit sphere wireframes for each shot size layer + const sizeRadii = [2.0, 2.5, 3.0]; // close-up, medium, wide + const sizeColors = [0x4a90d9, 0x50c878, 0xf39c12]; // blue, green, orange + + sizeRadii.forEach((radius, idx) => { + const orbitGeometry = new THREE.SphereGeometry(radius, 24, 24); + const orbitMaterial = new THREE.MeshBasicMaterial({ + color: sizeColors[idx], + wireframe: true, + transparent: true, + opacity: 0.15 + }); + const orbit = new THREE.Mesh(orbitGeometry, orbitMaterial); + group.add(orbit); + }); + + this.scene.add(group); + } + + createTextLabel(parent, text, x, y, z, color) { + // Create a simple sprite-based label + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#' + color.toString(16).padStart(6, '0'); + ctx.font = 'bold 48px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, 32, 32); + + const texture = new THREE.CanvasTexture(canvas); + const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true }); + const sprite = new THREE.Sprite(spriteMaterial); + sprite.position.set(x, y, z); + sprite.scale.set(0.3, 0.3, 1); + parent.add(sprite); + } + + createCameraMarkers() { + // Different radii for each shot size - close-up closer, wide further out + const sizeRadii = [2.0, 2.5, 3.0]; // close-up, medium, wide + const heightLevels = [-0.8, 0, 0.8, 1.6]; + + for (let dirIdx = 0; dirIdx < VIEW_DIRECTIONS.length; dirIdx++) { + const angle = (dirIdx / VIEW_DIRECTIONS.length) * Math.PI * 2; + + // Get direction-based color + const dirColor = getDirectionColor(dirIdx); + + for (let heightIdx = 0; heightIdx < HEIGHT_ANGLES.length; heightIdx++) { + const y = heightLevels[heightIdx]; + + for (let sizeIdx = 0; sizeIdx < SHOT_SIZES.length; sizeIdx++) { + // Each shot size is at a different radius + const radius = sizeRadii[sizeIdx]; + const x = Math.cos(angle) * radius; + const z = Math.sin(angle) * radius; + + const globalIdx = dirIdx * 12 + heightIdx * 3 + sizeIdx; + const sizeMultiplier = 0.8 + sizeIdx * 0.2; // Smaller cameras overall + + const cameraGroup = new THREE.Group(); + + // Camera body (colored by DIRECTION - front/back differentiation) + const bodyGeometry = new THREE.BoxGeometry( + 0.12 * sizeMultiplier, + 0.08 * sizeMultiplier, + 0.16 * sizeMultiplier + ); + const bodyMaterial = new THREE.MeshPhongMaterial({ + color: dirColor, + emissive: 0x000000, + shininess: 50 + }); + const body = new THREE.Mesh(bodyGeometry, bodyMaterial); + cameraGroup.add(body); + + // Camera lens + const lensGeometry = new THREE.CylinderGeometry( + 0.04 * sizeMultiplier, + 0.04 * sizeMultiplier, + 0.06 * sizeMultiplier, + 16 + ); + const lensMaterial = new THREE.MeshPhongMaterial({ + color: 0x333333, + emissive: 0x111111 + }); + const lens = new THREE.Mesh(lensGeometry, lensMaterial); + lens.rotation.x = Math.PI / 2; + lens.position.z = 0.1 * sizeMultiplier; + cameraGroup.add(lens); + + // Height indicator ring (colored by height) + const ringGeometry = new THREE.TorusGeometry(0.06 * sizeMultiplier, 0.012, 8, 16); + const ringMaterial = new THREE.MeshPhongMaterial({ + color: HEIGHT_COLORS[heightIdx], + emissive: 0x000000 + }); + const ring = new THREE.Mesh(ringGeometry, ringMaterial); + ring.rotation.x = Math.PI / 2; + ring.position.y = 0.06 * sizeMultiplier; + cameraGroup.add(ring); + + // Position and orient + cameraGroup.position.set(x, y, z); + cameraGroup.lookAt(0, 0, 0); + + // Store metadata + cameraGroup.userData = { + angleIndex: globalIdx, + angleData: CAMERA_ANGLES[globalIdx], + isCamera: true, + originalColor: dirColor, + heightIdx, + sizeIdx, + dirIdx + }; + + this.scene.add(cameraGroup); + this.cameraMeshes.push(cameraGroup); + } + } + } + } + + setupEventListeners() { + this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this)); + this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this)); + this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this)); + this.canvas.addEventListener('mouseleave', this.onMouseLeave.bind(this)); + this.canvas.addEventListener('wheel', this.onWheel.bind(this), { passive: false }); + + // Resize observer - maintains 1:1 aspect ratio + this.resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + const width = entry.contentRect.width; + const height = entry.contentRect.height; + if (width > 0 && height > 0 && this.camera && this.renderer) { + // Use the smaller dimension to maintain 1:1 aspect in the viewport + const size = Math.min(width, height); + this.camera.aspect = 1; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(size, size); + } + } + }); + this.resizeObserver.observe(this.viewportArea); + } + + startAnimation() { + const animate = () => { + this.animationId = requestAnimationFrame(animate); + this.renderer.render(this.scene, this.camera); + }; + animate(); + } + + // Mouse event handlers + onMouseDown(event) { + this.isDragging = true; + this.dragDistance = 0; + this.previousMousePosition = { + x: event.clientX, + y: event.clientY + }; + this.canvas.style.cursor = 'grabbing'; + } + + onMouseMove(event) { + const rect = this.canvas.getBoundingClientRect(); + this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + if (this.isDragging) { + const deltaX = event.clientX - this.previousMousePosition.x; + const deltaY = event.clientY - this.previousMousePosition.y; + + this.dragDistance += Math.abs(deltaX) + Math.abs(deltaY); + + // Rotate the scene + this.scene.rotation.y += deltaX * 0.005; + this.scene.rotation.x += deltaY * 0.005; + this.scene.rotation.x = Math.max(-Math.PI / 4, Math.min(Math.PI / 4, this.scene.rotation.x)); + + this.previousMousePosition = { + x: event.clientX, + y: event.clientY + }; + } else { + // Update tooltip on hover + this.updateTooltip(event); + } + } + + onMouseUp(event) { + // If minimal drag, treat as click + if (this.dragDistance < 5) { + this.handleClick(); + } + + this.isDragging = false; + this.canvas.style.cursor = 'grab'; + } + + onMouseLeave() { + this.isDragging = false; + this.canvas.style.cursor = 'grab'; + this.tooltip.style.display = 'none'; + } + + onWheel(event) { + event.preventDefault(); + + // Zoom in/out by moving camera along its forward direction + const zoomSpeed = 0.5; + const delta = event.deltaY > 0 ? zoomSpeed : -zoomSpeed; + + // Get current distance from origin + const currentDistance = this.camera.position.length(); + const newDistance = Math.max(5, Math.min(20, currentDistance + delta)); + + // Scale camera position to new distance + this.camera.position.normalize().multiplyScalar(newDistance); + } + + handleClick() { + this.raycaster.setFromCamera(this.mouse, this.camera); + + // Collect all meshes from visible camera groups + const allMeshes = []; + this.cameraMeshes.forEach(group => { + if (group.visible) { + group.traverse(child => { + if (child.isMesh) { + child.userData.parentGroup = group; + allMeshes.push(child); + } + }); + } + }); + + const intersects = this.raycaster.intersectObjects(allMeshes); + + if (intersects.length > 0) { + const clickedMesh = intersects[0].object; + const group = clickedMesh.userData.parentGroup; + + if (group && group.userData.isCamera) { + this.toggleSelection(group); + } + } + } + + toggleSelection(group) { + const index = group.userData.angleIndex; + + if (this.selectedIndices.has(index)) { + this.selectedIndices.delete(index); + this.setSelected(group, false); + } else { + this.selectedIndices.add(index); + this.setSelected(group, true); + } + + this.updateSelectionList(); + this.syncToWidget(); + } + + setSelected(group, isSelected) { + group.traverse(child => { + if (child.isMesh && child.material) { + if (isSelected) { + child.material.emissive.setHex(SELECTED_COLOR); + child.material.emissiveIntensity = 0.6; + } else { + child.material.emissive.setHex(0x000000); + child.material.emissiveIntensity = 0; + } + } + }); + } + + updateTooltip(event) { + this.raycaster.setFromCamera(this.mouse, this.camera); + + const allMeshes = []; + this.cameraMeshes.forEach(group => { + if (group.visible) { + group.traverse(child => { + if (child.isMesh) { + child.userData.parentGroup = group; + allMeshes.push(child); + } + }); + } + }); + + const intersects = this.raycaster.intersectObjects(allMeshes); + + if (intersects.length > 0) { + const hoveredMesh = intersects[0].object; + const group = hoveredMesh.userData.parentGroup; + + if (group && group.userData.isCamera) { + const data = group.userData.angleData; + const rect = this.canvas.getBoundingClientRect(); + const isSelected = this.selectedIndices.has(group.userData.angleIndex); + + this.tooltip.innerHTML = ` + ${data.direction}
+ ${data.height}
+ ${data.size}
+ ${isSelected ? '✓ Selected' : 'Click to select'} + `; + this.tooltip.style.display = 'block'; + this.tooltip.style.left = (event.clientX - rect.left + 10) + 'px'; + this.tooltip.style.top = (event.clientY - rect.top + 10) + 'px'; + return; + } + } + + this.tooltip.style.display = 'none'; + } + + // Sync selection to the hidden widget value + syncToWidget() { + const value = JSON.stringify(Array.from(this.selectedIndices).sort((a, b) => a - b)); + + // Find the selected_indices widget + const widget = this.node.widgets?.find(w => w.name === 'selected_indices'); + if (widget) { + widget.value = value; + if (widget.callback) { + widget.callback(value); + } + } + + // Mark graph as needing update + if (app.graph) { + app.graph.setDirtyCanvas(true, true); + } + } + + // Load selection state from widget + loadFromWidget() { + const widget = this.node.widgets?.find(w => w.name === 'selected_indices'); + if (widget && widget.value) { + try { + const indices = JSON.parse(widget.value); + if (Array.isArray(indices)) { + this.selectedIndices.clear(); + indices.forEach(idx => { + if (typeof idx === 'number' && idx >= 0 && idx < this.cameraMeshes.length) { + this.selectedIndices.add(idx); + this.setSelected(this.cameraMeshes[idx], true); + } + }); + this.updateSelectionList(); + } + } catch (e) { + console.error("CameraAngleSelector: Failed to parse widget value:", e); + } + } else { + this.updateSelectionList(); + } + } + + // Cleanup + destroy() { + if (this.animationId) { + cancelAnimationFrame(this.animationId); + } + + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + + if (this.renderer) { + this.renderer.dispose(); + this.renderer.forceContextLoss(); + } + + // Dispose geometries and materials + this.scene?.traverse(child => { + if (child.isMesh) { + child.geometry?.dispose(); + if (Array.isArray(child.material)) { + child.material.forEach(m => m.dispose()); + } else { + child.material?.dispose(); + } + } + }); + } +} + +/** + * Register the extension with ComfyUI + */ +app.registerExtension({ + name: "Comfy.CameraAngleSelector", + + async nodeCreated(node) { + // Only handle CameraAngleSelector nodes + if (node.comfyClass !== "CameraAngleSelector") { + return; + } + + console.log("CameraAngleSelector: Initializing 3D widget for node", node.id); + + // Create the 3D widget + const widget3D = new CameraAngle3DWidget(node); + + // Initialize async + const success = await widget3D.initialize(); + if (!success) { + console.error("CameraAngleSelector: Failed to initialize 3D widget"); + return; + } + + // Hide the original selected_indices widget + const selectedIndicesWidget = node.widgets?.find(w => w.name === 'selected_indices'); + if (selectedIndicesWidget) { + selectedIndicesWidget.type = 'hidden'; + // ComfyUI sometimes uses computeSize, set to minimal + if (selectedIndicesWidget.computeSize) { + selectedIndicesWidget.computeSize = () => [0, -4]; + } + } + + // Add the DOM widget to the node + const domWidget = node.addDOMWidget('camera_3d_view', 'customCanvas', widget3D.container, { + getValue: () => { + const w = node.widgets?.find(w => w.name === 'selected_indices'); + return w?.value || '[]'; + }, + setValue: (v) => { + const w = node.widgets?.find(w => w.name === 'selected_indices'); + if (w) w.value = v; + widget3D.loadFromWidget(); + } + }); + + // Store reference for cleanup + domWidget.widget3D = widget3D; + + // Set minimum size for the node + node.setSize([400, 650]); + + // Allow node to be resized + node.resizable = true; + + // Handle node removal + const originalOnRemoved = node.onRemoved; + node.onRemoved = function() { + widget3D.destroy(); + if (originalOnRemoved) { + originalOnRemoved.call(this); + } + }; + } +});