/** * 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"; import * as ThreeModule from "../vendor/three.module.js"; function loadThreeJS() { return Promise.resolve(ThreeModule); } const THREE = ThreeModule; function createInlineLabel(text) { const label = document.createElement('span'); label.textContent = text; label.style.cssText = 'color: #888; margin-right: 4px; font-size: 9px;'; return label; } function appendLegendItem(parent, marker, color, text, extraStyle = '') { const item = document.createElement('span'); if (extraStyle) item.style.cssText = extraStyle; const markerEl = document.createElement('b'); markerEl.textContent = marker; markerEl.style.color = color; item.appendChild(markerEl); item.appendChild(document.createTextNode(` ${text}`)); parent.appendChild(item); } // 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; const ANGLE_CAMERA_DISTANCE_OPTIONS = [0.6, 1.0, 1.8]; const ANGLE_CAMERA_DISTANCE_RADII = { 0.6: 1.55, 1.0: 2.25, 1.8: 2.75, }; const ANGLE_CAMERA_SUBJECT_HEIGHT = 2.75; const ANGLE_CAMERA_SUBJECT_MIN_WIDTH = 1.2; const ANGLE_CAMERA_SUBJECT_MAX_WIDTH = 3.0; const ANGLE_CAMERA_SUBJECT_OPACITY = 0.68; const ANGLE_CAMERA_WIDGET_MIN_HEIGHT = 420; const ANGLE_CAMERA_WIDGET_HEIGHT = 460; const ANGLE_CAMERA_WIDGET_NODE_CHROME = 190; const ANGLE_CAMERA_WIDGET_MARGIN = 10; function angleCameraRadius(distance) { return ANGLE_CAMERA_DISTANCE_RADII[distance] || ANGLE_CAMERA_DISTANCE_RADII[1.0]; } /** * 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.appendChild(createInlineLabel('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.appendChild(createInlineLabel('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.appendChild(createInlineLabel('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;'; appendLegendItem(legendRow, 'o', '#4a90d9', 'Close (inner)'); appendLegendItem(legendRow, 'o', '#50c878', 'Medium (middle)'); appendLegendItem(legendRow, 'o', '#f39c12', 'Wide (outer)'); const facingItem = document.createElement('span'); facingItem.style.marginLeft = 'auto'; const frontMarker = document.createElement('b'); frontMarker.textContent = '#'; frontMarker.style.color = '#4CAF50'; const backMarker = document.createElement('b'); backMarker.textContent = '#'; backMarker.style.color = '#F44336'; facingItem.appendChild(frontMarker); facingItem.appendChild(document.createTextNode(' Front ')); facingItem.appendChild(backMarker); facingItem.appendChild(document.createTextNode(' Back')); legendRow.appendChild(facingItem); 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; `; const title = document.createElement('span'); title.textContent = 'Selected Angles:'; const count = document.createElement('span'); count.id = 'selection-count-' + this.node.id; count.textContent = '0'; header.appendChild(title); header.appendChild(count); 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.replaceChildren(); 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); // Rotate the subject so its front faces +X (where "front view" cameras are) // The nose/face points +Z by default, cameras at dirIdx=0 are at +X group.rotation.y = Math.PI / 2; // 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.textContent = [ data.direction, data.height, data.size, isSelected ? 'Selected' : 'Click to select', ].join('\n'); this.tooltip.style.whiteSpace = 'pre-line'; this.tooltip.style.color = isSelected ? '#e94560' : '#fff'; 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(); } } }); } } class QwenAngleCameraWidget { constructor(node) { this.node = node; this.scene = null; this.camera = null; this.renderer = null; this.container = null; this.preview = null; this.promptEl = null; this.animationId = null; this.resizeObserver = null; this.isDragging = false; this.lastPointer = { x: 0, y: 0 }; this.subject = null; this.subjectFrame = null; this.subjectMaterial = null; this.cameraMarker = null; this.guideLine = null; this.railsGroup = null; this.elevationArc = null; this.lastPreviewUrl = null; this.previewTimer = null; this.lastStateKey = ""; this.lastRenderSize = { width: 0, height: 0 }; } widget(name) { return this.node.widgets?.find(w => w.name === name); } getValue(name, fallback) { const w = this.widget(name); const value = Number(w?.value); return Number.isFinite(value) ? value : fallback; } setValue(name, value) { const w = this.widget(name); if (w) { w.value = value; app.canvas?.setDirty?.(true, false); } } snap(value, options) { return options.reduce((best, current) => { const currentDistance = Math.abs(current - value); const bestDistance = Math.abs(best - value); return currentDistance < bestDistance || (Math.abs(currentDistance - bestDistance) <= 1e-9 && current > best) ? current : best; }); } getState() { return { azimuth: this.snap((this.getValue('azimuth', 0) + 360) % 360, [0, 45, 90, 135, 180, 225, 270, 315]), elevation: this.snap(this.getValue('elevation', 0), [-30, 0, 30, 60]), distance: this.snap(this.getValue('distance', 1.0), ANGLE_CAMERA_DISTANCE_OPTIONS), }; } cameraPrompt(state = this.getState()) { const azimuthNames = { 0: 'front view', 45: 'front-right quarter view', 90: 'right side view', 135: 'back-right quarter view', 180: 'back view', 225: 'back-left quarter view', 270: 'left side view', 315: 'front-left quarter view', }; const elevationNames = { '-30': 'low-angle shot', 0: 'eye-level shot', 30: 'elevated shot', 60: 'high-angle shot', }; const distanceNames = { '0.6': 'close-up', 1: 'medium shot', '1.8': 'wide shot', }; return ` ${azimuthNames[state.azimuth]} ${elevationNames[state.elevation]} ${distanceNames[state.distance]}`; } createContainer() { this.container = document.createElement('div'); this.container.className = 'qwen-angle-camera-control'; this.container.style.cssText = ` width: 100%; height: ${ANGLE_CAMERA_WIDGET_HEIGHT}px; min-height: ${ANGLE_CAMERA_WIDGET_MIN_HEIGHT}px; position: relative; overflow: hidden; background: #171820; border: 1px solid #34384a; border-radius: 6px; box-sizing: border-box; `; this.promptEl = document.createElement('div'); this.promptEl.style.cssText = ` position: absolute; left: 10px; right: 10px; bottom: 10px; z-index: 2; padding: 7px 9px; border-radius: 5px; background: rgba(0,0,0,0.72); color: #9ef7c4; font: 11px/1.35 monospace; white-space: nowrap; text-overflow: ellipsis; overflow-wrap: anywhere; overflow: hidden; pointer-events: none; `; this.container.appendChild(this.promptEl); const hint = document.createElement('div'); hint.textContent = 'drag: angle / wheel: distance'; hint.style.cssText = ` position: absolute; top: 8px; left: 10px; z-index: 2; color: rgba(255,255,255,0.72); font: 11px sans-serif; pointer-events: none; `; this.container.appendChild(hint); } async initialize() { await loadThreeJS(); this.createContainer(); this.initScene(); this.bindEvents(); this.updateScene(); this.start(); return true; } initScene() { this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x171820); this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100); this.camera.position.set(4.4, 3.2, 5.4); this.camera.lookAt(0, 0.65, 0); this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); this.renderer.domElement.style.cssText = ` display: block; width: 100%; height: 100%; `; this.container.insertBefore(this.renderer.domElement, this.promptEl); this.scene.add(new THREE.AmbientLight(0xffffff, 0.65)); const light = new THREE.DirectionalLight(0xffffff, 0.85); light.position.set(3, 6, 5); this.scene.add(light); this.scene.add(new THREE.GridHelper(5, 10, 0x34384a, 0x252936)); this.createRails(); this.subjectMaterial = new THREE.MeshBasicMaterial({ color: 0xd6d2c8, side: THREE.DoubleSide, transparent: true, opacity: ANGLE_CAMERA_SUBJECT_OPACITY, }); this.subject = new THREE.Mesh( new THREE.PlaneGeometry(1.65, ANGLE_CAMERA_SUBJECT_HEIGHT), this.subjectMaterial ); this.subject.position.set(0, 0.85, 0); this.scene.add(this.subject); this.subjectFrame = new THREE.Mesh( new THREE.BoxGeometry(1.73, ANGLE_CAMERA_SUBJECT_HEIGHT + 0.08, 0.03), new THREE.MeshStandardMaterial({ color: 0x2b2f3d, roughness: 0.7 }) ); this.subjectFrame.position.copy(this.subject.position); this.subjectFrame.position.z = -0.025; this.scene.add(this.subjectFrame); const camBody = new THREE.Group(); camBody.add(new THREE.Mesh( new THREE.BoxGeometry(0.28, 0.2, 0.32), new THREE.MeshStandardMaterial({ color: 0x6aa7ff, metalness: 0.25, roughness: 0.35 }) )); const lens = new THREE.Mesh( new THREE.CylinderGeometry(0.075, 0.095, 0.18, 18), new THREE.MeshStandardMaterial({ color: 0x9ec6ff, metalness: 0.25, roughness: 0.25 }) ); lens.rotation.x = Math.PI / 2; lens.position.z = 0.24; camBody.add(lens); this.cameraMarker = camBody; this.scene.add(this.cameraMarker); const lineGeo = new THREE.BufferGeometry(); this.guideLine = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0x9ef7c4 })); this.scene.add(this.guideLine); this.resizeObserver = new ResizeObserver(() => this.resize()); this.resizeObserver.observe(this.container); this.resize(); this.updateSubjectTexture(); this.previewTimer = window.setInterval(() => this.updateSubjectTexture(), 1000); } createRails() { const targetY = 0.85; this.railsGroup = new THREE.Group(); const ringMaterial = new THREE.LineBasicMaterial({ color: 0x465063, transparent: true, opacity: 0.72 }); for (const radius of ANGLE_CAMERA_DISTANCE_OPTIONS.map(angleCameraRadius)) { const points = []; for (let i = 0; i <= 96; i++) { const t = (i / 96) * Math.PI * 2; points.push(new THREE.Vector3(Math.sin(t) * radius, targetY, Math.cos(t) * radius)); } this.railsGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), ringMaterial)); } this.elevationArc = new THREE.Line( new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0x9b72ff, transparent: true, opacity: 0.78 }) ); this.railsGroup.add(this.elevationArc); this.scene.add(this.railsGroup); } updateElevationArc(state) { if (!this.elevationArc) return; const targetY = 0.85; const radius = angleCameraRadius(state.distance); const theta = THREE.MathUtils.degToRad(state.azimuth); const points = []; for (let elev = 0; elev <= 90; elev += 3) { const phi = THREE.MathUtils.degToRad(elev); points.push(new THREE.Vector3( Math.sin(theta) * radius * Math.cos(phi), targetY + radius * Math.sin(phi), Math.cos(theta) * radius * Math.cos(phi) )); } this.elevationArc.geometry.dispose(); this.elevationArc.geometry = new THREE.BufferGeometry().setFromPoints(points); } linkedPreviewUrl() { const input = this.node.inputs?.find(i => i.name === 'image'); if (!input?.link || !app.graph?.links) return null; const link = app.graph.links[input.link]; const originId = Array.isArray(link) ? link[1] : link?.origin_id; if (originId == null) return null; const origin = app.graph.getNodeById ? app.graph.getNodeById(originId) : app.graph._nodes_by_id?.[originId]; if (!origin || origin.type !== 'LoadImage') return null; const imageWidget = origin.widgets?.find(w => w.name === 'image'); let filename = imageWidget?.value || origin.widgets_values?.[0]; if (!filename || typeof filename !== 'string') return null; let type = 'input'; const match = filename.match(/\s+\[(input|output|temp)\]$/); if (match) { type = match[1]; filename = filename.replace(/\s+\[(input|output|temp)\]$/, ''); } return `/view?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=`; } updateSubjectTexture() { if (!this.subjectMaterial || !THREE) return; const url = this.linkedPreviewUrl(); if (url === this.lastPreviewUrl) return; this.lastPreviewUrl = url; if (!url) { this.subjectMaterial.map = null; this.subjectMaterial.color.set(0xd6d2c8); this.subjectMaterial.needsUpdate = true; return; } const loader = new THREE.TextureLoader(); loader.load(url, (texture) => { texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; this.subjectMaterial.map = texture; this.subjectMaterial.color.set(0xffffff); this.subjectMaterial.needsUpdate = true; const image = texture.image; if (image?.width && image?.height && this.subject) { const aspect = image.width / image.height; const height = ANGLE_CAMERA_SUBJECT_HEIGHT; const width = Math.max( ANGLE_CAMERA_SUBJECT_MIN_WIDTH, Math.min(ANGLE_CAMERA_SUBJECT_MAX_WIDTH, height * aspect) ); this.subject.geometry.dispose(); this.subject.geometry = new THREE.PlaneGeometry(width, height); if (this.subjectFrame) { this.subjectFrame.geometry.dispose(); this.subjectFrame.geometry = new THREE.BoxGeometry(width + 0.08, height + 0.08, 0.03); } } }); } updateScene() { const state = this.getState(); const stateKey = `${state.azimuth}|${state.elevation}|${state.distance}`; if (stateKey === this.lastStateKey) return; this.lastStateKey = stateKey; this.setValue('azimuth', state.azimuth); this.setValue('elevation', state.elevation); this.setValue('distance', state.distance); const radius = angleCameraRadius(state.distance); const theta = THREE.MathUtils.degToRad(state.azimuth); const phi = THREE.MathUtils.degToRad(state.elevation); const target = new THREE.Vector3(0, 0.85, 0); const pos = new THREE.Vector3( radius * Math.sin(theta) * Math.cos(phi), target.y + radius * Math.sin(phi), radius * Math.cos(theta) * Math.cos(phi) ); this.cameraMarker.position.copy(pos); this.cameraMarker.lookAt(target); this.guideLine.geometry.setFromPoints([target, pos]); this.updateElevationArc(state); this.promptEl.textContent = this.cameraPrompt(state); } bindEvents() { this.container.addEventListener('pointerdown', (event) => { event.preventDefault(); event.stopPropagation(); this.isDragging = true; this.lastPointer = { x: event.clientX, y: event.clientY }; this.container.setPointerCapture(event.pointerId); }); this.container.addEventListener('pointermove', (event) => { event.preventDefault(); event.stopPropagation(); if (!this.isDragging) return; const dx = event.clientX - this.lastPointer.x; const dy = event.clientY - this.lastPointer.y; this.lastPointer = { x: event.clientX, y: event.clientY }; this.setValue('azimuth', (this.getValue('azimuth', 0) + dx * 0.6 + 360) % 360); this.setValue('elevation', Math.max(-30, Math.min(60, this.getValue('elevation', 0) - dy * 0.35))); this.updateScene(); }); this.container.addEventListener('pointerup', (event) => { event.preventDefault(); event.stopPropagation(); this.isDragging = false; this.container.releasePointerCapture(event.pointerId); this.updateScene(); }); this.container.addEventListener('pointercancel', (event) => { event.preventDefault(); event.stopPropagation(); this.isDragging = false; }); this.container.addEventListener('wheel', (event) => { event.preventDefault(); event.stopPropagation(); const current = this.getState().distance; const currentIndex = ANGLE_CAMERA_DISTANCE_OPTIONS.indexOf(current); const direction = event.deltaY > 0 ? 1 : -1; const nextIndex = Math.max( 0, Math.min(ANGLE_CAMERA_DISTANCE_OPTIONS.length - 1, currentIndex + direction) ); this.setValue('distance', ANGLE_CAMERA_DISTANCE_OPTIONS[nextIndex]); this.updateScene(); }, { passive: false }); } resize() { if (!this.renderer || !this.container) return; const nodeWidth = Number(this.node?.size?.[0]) || 430; const nodeHeight = Number(this.node?.size?.[1]) || 720; const hostWidth = Math.max(260, Math.floor(nodeWidth - 28)); const hostHeight = Math.max( ANGLE_CAMERA_WIDGET_MIN_HEIGHT, Math.floor(nodeHeight - ANGLE_CAMERA_WIDGET_NODE_CHROME) ); const width = Math.max(240, hostWidth - ANGLE_CAMERA_WIDGET_MARGIN * 2); const height = Math.max( ANGLE_CAMERA_WIDGET_MIN_HEIGHT - ANGLE_CAMERA_WIDGET_MARGIN * 2, hostHeight - ANGLE_CAMERA_WIDGET_MARGIN * 2 ); this.container.style.width = `${width}px`; this.container.style.minWidth = `${width}px`; this.container.style.maxWidth = `${width}px`; this.container.style.height = `${height}px`; this.container.style.minHeight = `${height}px`; this.container.style.maxHeight = 'none'; this.container.style.marginLeft = 'auto'; this.container.style.marginRight = 'auto'; this.container.style.marginTop = `${ANGLE_CAMERA_WIDGET_MARGIN}px`; this.container.style.marginBottom = `${ANGLE_CAMERA_WIDGET_MARGIN}px`; const host = this.container.parentElement; if (host) { host.style.width = `${hostWidth}px`; host.style.minWidth = `${hostWidth}px`; host.style.maxWidth = `${hostWidth}px`; host.style.height = `${hostHeight}px`; host.style.minHeight = `${hostHeight}px`; host.style.maxHeight = 'none'; host.style.overflow = 'visible'; host.style.boxSizing = 'border-box'; } if (width === this.lastRenderSize.width && height === this.lastRenderSize.height) { return; } this.lastRenderSize = { width, height }; this.renderer.setSize(width, height, false); this.camera.aspect = width / height; const target = new THREE.Vector3(0, 0.85, 0); const aspectBoost = Math.max(1, 1.15 / this.camera.aspect); this.camera.position.set(4.4 * aspectBoost, 3.2 * aspectBoost, 5.4 * aspectBoost); this.camera.lookAt(target); this.camera.updateProjectionMatrix(); } start() { const render = () => { this.animationId = requestAnimationFrame(render); this.resize(); this.updateScene(); this.renderer.render(this.scene, this.camera); }; render(); } destroy() { if (this.animationId) cancelAnimationFrame(this.animationId); if (this.previewTimer) window.clearInterval(this.previewTimer); if (this.resizeObserver) this.resizeObserver.disconnect(); if (this.renderer) { this.renderer.dispose(); this.renderer.forceContextLoss(); } } } /** * Register the extension with ComfyUI */ app.registerExtension({ name: "Comfy.CameraAngleSelector", async nodeCreated(node) { if (node.comfyClass === "QwenImageEdit2511AngleCamera") { const widget3D = new QwenAngleCameraWidget(node); const success = await widget3D.initialize(); if (!success) return; const domWidget = node.addDOMWidget('qwen_angle_camera_view', 'customCanvas', widget3D.container, { getValue: () => JSON.stringify(widget3D.getState()), setValue: () => widget3D.updateScene(), }); domWidget.widget3D = widget3D; domWidget.computeSize = () => [ Math.max(260, Math.floor((node.size?.[0] || 430) - 28)), ANGLE_CAMERA_WIDGET_HEIGHT + ANGLE_CAMERA_WIDGET_MARGIN * 2 ]; if (!node.size || node.size[0] < 430 || node.size[1] < 720) { node.setSize([ Math.max(430, node.size?.[0] || 430), Math.max(720, node.size?.[1] || 720) ]); } node.resizable = true; const originalOnResize = node.onResize; node.onResize = function(size) { if (originalOnResize) originalOnResize.call(this, size); requestAnimationFrame(() => { widget3D.lastRenderSize = { width: 0, height: 0 }; widget3D.resize(); app.canvas?.setDirty?.(true, true); }); }; for (const name of ['azimuth', 'elevation', 'distance']) { const widget = node.widgets?.find(w => w.name === name); if (widget?.callback) { const original = widget.callback; widget.callback = function(value) { original.call(this, value); widget3D.updateScene(); }; } } const originalOnRemoved = node.onRemoved; node.onRemoved = function() { widget3D.destroy(); if (originalOnRemoved) originalOnRemoved.call(this); }; return; } // 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); } }; } });