import { app } from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js"; import * as THREE from "https://esm.sh/three@0.170.0"; // ============== Styles ============== const WIDGET_STYLES = ` .synvow-multiangle-container { width: 100%; height: 100%; position: relative; background: #0a0a0f; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; border-radius: 8px; overflow: hidden; } .synvow-multiangle-canvas { width: 100%; height: 100%; display: block; } .synvow-multiangle-prompt { position: absolute; top: 8px; left: 8px; right: 8px; background: rgba(10, 10, 15, 0.9); border: 1px solid rgba(233, 61, 130, 0.3); border-radius: 6px; padding: 6px 10px; font-size: 11px; color: #E93D82; backdrop-filter: blur(4px); font-family: 'Consolas', 'Monaco', monospace; word-break: break-all; line-height: 1.4; } .synvow-multiangle-info { position: absolute; bottom: 8px; left: 8px; right: 8px; background: rgba(10, 10, 15, 0.9); border: 1px solid rgba(233, 61, 130, 0.3); border-radius: 6px; padding: 8px 12px; font-size: 11px; color: #e0e0e0; display: flex; justify-content: space-around; align-items: center; backdrop-filter: blur(4px); } .synvow-multiangle-param { text-align: center; } .synvow-multiangle-param-label { color: #888; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; } .synvow-multiangle-param-value { font-weight: 600; font-size: 13px; } .synvow-multiangle-param-value.azimuth { color: #E93D82; } .synvow-multiangle-param-value.elevation { color: #00FFD0; } .synvow-multiangle-param-value.zoom { color: #FFB800; } .synvow-multiangle-reset { width: 24px; height: 24px; border-radius: 4px; border: 1px solid rgba(233, 61, 130, 0.4); background: rgba(10, 10, 15, 0.8); color: #E93D82; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; transition: all 0.2s ease; flex-shrink: 0; } .synvow-multiangle-reset:hover { background: rgba(233, 61, 130, 0.2); border-color: #E93D82; } .synvow-multiangle-dropdowns { position: absolute; top: 40px; left: 8px; display: flex; flex-direction: column; gap: 4px; z-index: 10; } .synvow-multiangle-dropdown { display: flex; align-items: center; gap: 6px; } .synvow-multiangle-dropdown-label { font-size: 9px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; min-width: 28px; text-align: right; white-space: nowrap; } .synvow-multiangle-dropdown-label.azimuth { color: #E93D82; } .synvow-multiangle-dropdown-label.elevation { color: #00FFD0; } .synvow-multiangle-dropdown-label.distance { color: #FFB800; } .synvow-multiangle-select { background: rgba(10, 10, 15, 0.9); border: 1px solid rgba(100, 100, 120, 0.4); border-radius: 4px; padding: 3px 6px; font-size: 10px; color: #e0e0e0; cursor: pointer; outline: none; min-width: 120px; backdrop-filter: blur(4px); } .synvow-multiangle-select:hover { border-color: rgba(150, 150, 170, 0.6); } .synvow-multiangle-select:focus { border-color: #E93D82; } .synvow-multiangle-select option { background: #1a1a2e; color: #e0e0e0; } .synvow-multiangle-upload-btn { width: 24px; height: 24px; border-radius: 4px; border: 1px solid rgba(100, 180, 255, 0.4); background: rgba(10, 10, 15, 0.8); color: #64B4FF; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; transition: all 0.2s ease; flex-shrink: 0; margin-right: 4px; } .synvow-multiangle-upload-btn:hover { background: rgba(100, 180, 255, 0.2); border-color: #64B4FF; } .synvow-multiangle-file-input { display: none; } .synvow-multiangle-drop-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(233, 61, 130, 0.15); border: 3px dashed #E93D82; border-radius: 8px; display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transition: opacity 0.2s ease; z-index: 100; } .synvow-multiangle-drop-overlay.active { opacity: 1; } .synvow-multiangle-drop-text { color: #E93D82; font-size: 14px; font-weight: 600; text-shadow: 0 0 10px rgba(233, 61, 130, 0.5); } `; function injectStyles() { if (document.getElementById('synvow-multiangle-styles')) return; const style = document.createElement('style'); style.id = 'synvow-multiangle-styles'; style.textContent = WIDGET_STYLES; document.head.appendChild(style); } // ============== Translations ============== const translations = { horizontal: 'H', vertical: 'V', zoom: 'Z', horizontalFull: 'HORIZONTAL', verticalFull: 'VERTICAL', zoomFull: 'ZOOM', resetToDefaults: 'Reset to defaults', loadImage: 'Load image', dropImageHere: 'Drop image here', frontView: 'front view', frontRightQuarterView: 'front-right quarter view', rightSideView: 'right side view', backRightQuarterView: 'back-right quarter view', backView: 'back view', backLeftQuarterView: 'back-left quarter view', leftSideView: 'left side view', frontLeftQuarterView: 'front-left quarter view', extremeLowAngleShot: 'extreme low-angle shot', lowAngleShot: 'low-angle shot', eyeLevelShot: 'eye-level shot', elevatedShot: 'elevated shot', highAngleShot: 'high-angle shot', wideShot: 'wide shot', mediumShot: 'medium shot', closeUp: 'close-up' }; function t(key) { return translations[key] || key; } // ============== CameraWidget Class ============== class CameraWidget { constructor(options) { injectStyles(); this.container = options.container; this.onStateChange = options.onStateChange; this.state = { azimuth: options.initialState?.azimuth ?? 0, elevation: options.initialState?.elevation ?? 0, distance: options.initialState?.distance ?? 5, imageUrl: null, uploadedImageFile: null }; this.node = options.node; this.liveAzimuth = this.state.azimuth; this.liveElevation = this.state.elevation; this.liveDistance = this.state.distance; this.CENTER = new THREE.Vector3(0, 0.5, 0); this.AZIMUTH_RADIUS = 1.8; this.ELEVATION_RADIUS = 1.4; this.ELEV_ARC_X = -0.8; this.isDragging = false; this.dragTarget = null; this.hoveredHandle = null; this.raycaster = new THREE.Raycaster(); this.mouse = new THREE.Vector2(); this.animationId = null; this.time = 0; this.needsRender = true; this.AZIMUTH_OPTIONS = [ { key: 'frontView', value: 0 }, { key: 'frontRightQuarterView', value: 45 }, { key: 'rightSideView', value: 90 }, { key: 'backRightQuarterView', value: 135 }, { key: 'backView', value: 180 }, { key: 'backLeftQuarterView', value: 225 }, { key: 'leftSideView', value: 270 }, { key: 'frontLeftQuarterView', value: 315 } ]; this.ELEVATION_OPTIONS = [ { key: 'extremeLowAngleShot', value: -60 }, { key: 'lowAngleShot', value: -30 }, { key: 'eyeLevelShot', value: 0 }, { key: 'elevatedShot', value: 30 }, { key: 'highAngleShot', value: 60 } ]; this.DISTANCE_OPTIONS = [ { key: 'wideShot', value: 1 }, { key: 'mediumShot', value: 4 }, { key: 'closeUp', value: 8 } ]; this.createDOM(); this.initThreeJS(); this.bindEvents(); this.updateDisplay(); this.animate(); } createDOM() { const azimuthOptionsHtml = this.AZIMUTH_OPTIONS.map(opt => `` ).join(''); const elevationOptionsHtml = this.ELEVATION_OPTIONS.map(opt => `` ).join(''); const distanceOptionsHtml = this.DISTANCE_OPTIONS.map(opt => `` ).join(''); this.container.innerHTML = `
${t('dropImageHere')}
<sks> front view eye-level shot medium shot
${t('horizontal')}
${t('vertical')}
${t('zoom')}
${t('horizontalFull')}
0°
${t('verticalFull')}
0°
${t('zoomFull')}
5.0
`; const containerEl = this.container.querySelector('.synvow-multiangle-container'); this.containerEl = containerEl; this.canvasContainer = containerEl.querySelector('.synvow-multiangle-canvas'); this.dropOverlay = containerEl.querySelector('.synvow-multiangle-drop-overlay'); this.promptEl = containerEl.querySelector('.synvow-multiangle-prompt'); this.hValueEl = containerEl.querySelector('.synvow-multiangle-param-value.azimuth'); this.vValueEl = containerEl.querySelector('.synvow-multiangle-param-value.elevation'); this.zValueEl = containerEl.querySelector('.synvow-multiangle-param-value.zoom'); this.azimuthSelect = containerEl.querySelector('.synvow-multiangle-select.azimuth'); this.elevationSelect = containerEl.querySelector('.synvow-multiangle-select.elevation'); this.distanceSelect = containerEl.querySelector('.synvow-multiangle-select.distance'); containerEl.querySelector('.synvow-multiangle-reset').addEventListener('click', () => this.resetToDefaults()); this.fileInput = containerEl.querySelector('.synvow-multiangle-file-input'); containerEl.querySelector('.synvow-multiangle-upload-btn').addEventListener('click', () => this.fileInput.click()); this.fileInput.addEventListener('change', (e) => this.handleFileUpload(e)); this.azimuthSelect.addEventListener('change', () => { const value = parseInt(this.azimuthSelect.value, 10); this.state.azimuth = value; this.liveAzimuth = value; this.updateVisuals(); this.updateDisplay(); this.notifyStateChange(); }); this.elevationSelect.addEventListener('change', () => { const value = parseInt(this.elevationSelect.value, 10); this.state.elevation = value; this.liveElevation = value; this.updateVisuals(); this.updateDisplay(); this.notifyStateChange(); }); this.distanceSelect.addEventListener('change', () => { const value = parseInt(this.distanceSelect.value, 10); this.state.distance = value; this.liveDistance = value; this.updateVisuals(); this.updateDisplay(); this.notifyStateChange(); }); } initThreeJS() { const width = this.canvasContainer.clientWidth || 300; const height = this.canvasContainer.clientHeight || 300; this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x0a0a0f); this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); this.camera.position.set(4, 3.5, 4); this.camera.lookAt(0, 0.3, 0); this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); this.renderer.setSize(width, height); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.canvasContainer.appendChild(this.renderer.domElement); this.scene.add(new THREE.AmbientLight(0xffffff, 0.4)); const mainLight = new THREE.DirectionalLight(0xffffff, 0.8); mainLight.position.set(5, 10, 5); this.scene.add(mainLight); const fillLight = new THREE.DirectionalLight(0xE93D82, 0.3); fillLight.position.set(-5, 5, -5); this.scene.add(fillLight); this.gridHelper = new THREE.GridHelper(5, 10, 0x1a1a2e, 0x12121a); this.gridHelper.position.y = -0.01; this.scene.add(this.gridHelper); this.createSubject(); this.createCameraIndicator(); this.createAzimuthRing(); this.createElevationArc(); this.createDistanceHandle(); this.updateVisuals(); } createGridTexture() { const canvas = document.createElement('canvas'); const size = 128; canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#1a1a2a'; ctx.fillRect(0, 0, size, size); ctx.strokeStyle = '#2a2a3a'; ctx.lineWidth = 1; const gridSize = 16; for (let i = 0; i <= size; i += gridSize) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, size); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(size, i); ctx.stroke(); } const texture = new THREE.CanvasTexture(canvas); texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; texture.repeat.set(4, 4); return texture; } createSubject() { const cardGeo = new THREE.BoxGeometry(1.2, 1.2, 0.02); const frontMat = new THREE.MeshBasicMaterial({ color: 0x3a3a4a }); const backMat = new THREE.MeshBasicMaterial({ map: this.createGridTexture() }); const edgeMat = new THREE.MeshBasicMaterial({ color: 0x1a1a2a }); this.imagePlane = new THREE.Mesh(cardGeo, [edgeMat, edgeMat, edgeMat, edgeMat, frontMat, backMat]); this.imagePlane.position.copy(this.CENTER); this.scene.add(this.imagePlane); this.planeMat = frontMat; const frameGeo = new THREE.EdgesGeometry(cardGeo); this.imageFrame = new THREE.LineSegments(frameGeo, new THREE.LineBasicMaterial({ color: 0xE93D82 })); this.imageFrame.position.copy(this.CENTER); this.scene.add(this.imageFrame); const glowRingMat = new THREE.MeshBasicMaterial({ color: 0xE93D82, transparent: true, opacity: 0.4, side: THREE.DoubleSide }); this.glowRing = new THREE.Mesh(new THREE.RingGeometry(0.55, 0.58, 24), glowRingMat); this.glowRing.position.set(0, 0.01, 0); this.glowRing.rotation.x = -Math.PI / 2; this.scene.add(this.glowRing); } createCameraIndicator() { const camMat = new THREE.MeshBasicMaterial({ color: 0xE93D82 }); this.cameraIndicator = new THREE.Mesh(new THREE.ConeGeometry(0.15, 0.4, 4), camMat); this.scene.add(this.cameraIndicator); const camGlowMat = new THREE.MeshBasicMaterial({ color: 0xff6ba8, transparent: true, opacity: 0.8 }); this.camGlow = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 8), camGlowMat); this.scene.add(this.camGlow); } createAzimuthRing() { const azRingMat = new THREE.MeshBasicMaterial({ color: 0xE93D82, transparent: true, opacity: 0.7 }); this.azimuthRing = new THREE.Mesh(new THREE.TorusGeometry(this.AZIMUTH_RADIUS, 0.04, 8, 48), azRingMat); this.azimuthRing.rotation.x = Math.PI / 2; this.azimuthRing.position.y = 0.02; this.scene.add(this.azimuthRing); const azHandleMat = new THREE.MeshBasicMaterial({ color: 0xE93D82 }); this.azimuthHandle = new THREE.Mesh(new THREE.SphereGeometry(0.16, 12, 12), azHandleMat); this.scene.add(this.azimuthHandle); const azGlowMat = new THREE.MeshBasicMaterial({ color: 0xE93D82, transparent: true, opacity: 0.2 }); this.azGlow = new THREE.Mesh(new THREE.SphereGeometry(0.22, 8, 8), azGlowMat); this.scene.add(this.azGlow); } createElevationArc() { const arcPoints = []; for (let i = 0; i <= 16; i++) { const angle = (-60 + (120 * i / 16)) * Math.PI / 180; arcPoints.push(new THREE.Vector3( this.ELEV_ARC_X, this.ELEVATION_RADIUS * Math.sin(angle) + this.CENTER.y, this.ELEVATION_RADIUS * Math.cos(angle) )); } const arcCurve = new THREE.CatmullRomCurve3(arcPoints); const elArcMat = new THREE.MeshBasicMaterial({ color: 0x00FFD0, transparent: true, opacity: 0.8 }); this.elevationArc = new THREE.Mesh(new THREE.TubeGeometry(arcCurve, 16, 0.04, 6, false), elArcMat); this.scene.add(this.elevationArc); const elHandleMat = new THREE.MeshBasicMaterial({ color: 0x00FFD0 }); this.elevationHandle = new THREE.Mesh(new THREE.SphereGeometry(0.16, 12, 12), elHandleMat); this.scene.add(this.elevationHandle); const elGlowMat = new THREE.MeshBasicMaterial({ color: 0x00FFD0, transparent: true, opacity: 0.2 }); this.elGlow = new THREE.Mesh(new THREE.SphereGeometry(0.22, 8, 8), elGlowMat); this.scene.add(this.elGlow); } createDistanceHandle() { const distHandleMat = new THREE.MeshBasicMaterial({ color: 0xFFB800 }); this.distanceHandle = new THREE.Mesh(new THREE.SphereGeometry(0.15, 12, 12), distHandleMat); this.scene.add(this.distanceHandle); const distGlowMat = new THREE.MeshBasicMaterial({ color: 0xFFB800, transparent: true, opacity: 0.25 }); this.distGlow = new THREE.Mesh(new THREE.SphereGeometry(0.22, 8, 8), distGlowMat); this.scene.add(this.distGlow); } updateDistanceLine(start, end) { if (!this.distanceLine) { const lineMat = new THREE.LineBasicMaterial({ color: 0xFFB800, transparent: true, opacity: 0.8 }); this.distanceLine = new THREE.Line(new THREE.BufferGeometry(), lineMat); this.scene.add(this.distanceLine); } const positions = new Float32Array([start.x, start.y, start.z, end.x, end.y, end.z]); this.distanceLine.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); this.distanceLine.geometry.attributes.position.needsUpdate = true; } updateVisuals() { const azRad = (this.liveAzimuth * Math.PI) / 180; const elRad = (this.liveElevation * Math.PI) / 180; const visualDist = 2.6 - (this.liveDistance / 10) * 2.0; const camX = visualDist * Math.sin(azRad) * Math.cos(elRad); const camY = this.CENTER.y + visualDist * Math.sin(elRad); const camZ = visualDist * Math.cos(azRad) * Math.cos(elRad); this.cameraIndicator.position.set(camX, camY, camZ); this.cameraIndicator.lookAt(this.CENTER); this.cameraIndicator.rotateX(Math.PI / 2); this.camGlow.position.copy(this.cameraIndicator.position); const azX = this.AZIMUTH_RADIUS * Math.sin(azRad); const azZ = this.AZIMUTH_RADIUS * Math.cos(azRad); this.azimuthHandle.position.set(azX, 0.16, azZ); this.azGlow.position.copy(this.azimuthHandle.position); const elY = this.CENTER.y + this.ELEVATION_RADIUS * Math.sin(elRad); const elZ = this.ELEVATION_RADIUS * Math.cos(elRad); this.elevationHandle.position.set(this.ELEV_ARC_X, elY, elZ); this.elGlow.position.copy(this.elevationHandle.position); const distT = 0.15 + ((10 - this.liveDistance) / 10) * 0.7; this.distanceHandle.position.lerpVectors(this.CENTER, this.cameraIndicator.position, distT); this.distGlow.position.copy(this.distanceHandle.position); this.updateDistanceLine(this.CENTER.clone(), this.cameraIndicator.position.clone()); this.requestRender(); } bindEvents() { const canvas = this.renderer.domElement; canvas.addEventListener('mousedown', (e) => this.onPointerDown(e)); canvas.addEventListener('mousemove', (e) => this.onPointerMove(e)); canvas.addEventListener('mouseup', () => this.onPointerUp()); canvas.addEventListener('mouseleave', () => this.onPointerUp()); canvas.addEventListener('touchstart', (e) => { e.preventDefault(); this.onPointerDown({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY }); }, { passive: false }); canvas.addEventListener('touchmove', (e) => { e.preventDefault(); this.onPointerMove({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY }); }, { passive: false }); canvas.addEventListener('touchend', () => this.onPointerUp()); // Drag and drop events this.containerEl.addEventListener('dragenter', (e) => this.onDragEnter(e)); this.containerEl.addEventListener('dragover', (e) => this.onDragOver(e)); this.containerEl.addEventListener('dragleave', (e) => this.onDragLeave(e)); this.containerEl.addEventListener('drop', (e) => this.onDrop(e)); new ResizeObserver(() => this.onResize()).observe(this.canvasContainer); } getMousePos(event) { const rect = this.renderer.domElement.getBoundingClientRect(); this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; } setHandleScale(handle, glow, scale) { handle.scale.setScalar(scale); if (glow) glow.scale.setScalar(scale); } onPointerDown(event) { this.getMousePos(event); this.raycaster.setFromCamera(this.mouse, this.camera); const handles = [ { mesh: this.azimuthHandle, glow: this.azGlow, name: 'azimuth' }, { mesh: this.elevationHandle, glow: this.elGlow, name: 'elevation' }, { mesh: this.distanceHandle, glow: this.distGlow, name: 'distance' } ]; for (const h of handles) { if (this.raycaster.intersectObject(h.mesh).length > 0) { this.isDragging = true; this.dragTarget = h.name; this.setHandleScale(h.mesh, h.glow, 1.3); this.renderer.domElement.style.cursor = 'grabbing'; return; } } } onPointerMove(event) { this.getMousePos(event); this.raycaster.setFromCamera(this.mouse, this.camera); if (!this.isDragging) { const handles = [ { mesh: this.azimuthHandle, glow: this.azGlow, name: 'azimuth' }, { mesh: this.elevationHandle, glow: this.elGlow, name: 'elevation' }, { mesh: this.distanceHandle, glow: this.distGlow, name: 'distance' } ]; let foundHover = null; for (const h of handles) { if (this.raycaster.intersectObject(h.mesh).length > 0) { foundHover = h; break; } } if (this.hoveredHandle && this.hoveredHandle !== foundHover) { this.setHandleScale(this.hoveredHandle.mesh, this.hoveredHandle.glow, 1.0); } if (foundHover) { this.setHandleScale(foundHover.mesh, foundHover.glow, 1.15); this.renderer.domElement.style.cursor = 'grab'; this.hoveredHandle = foundHover; } else { this.renderer.domElement.style.cursor = 'default'; this.hoveredHandle = null; } return; } const plane = new THREE.Plane(); const intersect = new THREE.Vector3(); if (this.dragTarget === 'azimuth') { plane.setFromNormalAndCoplanarPoint(new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0)); if (this.raycaster.ray.intersectPlane(plane, intersect)) { let angle = Math.atan2(intersect.x, intersect.z) * (180 / Math.PI); if (angle < 0) angle += 360; this.liveAzimuth = Math.max(0, Math.min(360, angle)); this.state.azimuth = Math.round(this.liveAzimuth); this.updateDisplay(); this.updateVisuals(); this.notifyStateChange(); } } else if (this.dragTarget === 'elevation') { const elevPlane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -this.ELEV_ARC_X); if (this.raycaster.ray.intersectPlane(elevPlane, intersect)) { const relY = intersect.y - this.CENTER.y; const relZ = intersect.z; let angle = Math.atan2(relY, relZ) * (180 / Math.PI); angle = Math.max(-60, Math.min(60, angle)); this.liveElevation = angle; this.state.elevation = Math.round(this.liveElevation); this.updateDisplay(); this.updateVisuals(); this.notifyStateChange(); } } else if (this.dragTarget === 'distance') { const newDist = 5 - this.mouse.y * 5; this.liveDistance = Math.max(0, Math.min(10, newDist)); this.state.distance = Math.round(this.liveDistance * 10) / 10; this.updateDisplay(); this.updateVisuals(); this.notifyStateChange(); } } onPointerUp() { if (this.isDragging) { const handles = [ { mesh: this.azimuthHandle, glow: this.azGlow }, { mesh: this.elevationHandle, glow: this.elGlow }, { mesh: this.distanceHandle, glow: this.distGlow } ]; handles.forEach(h => this.setHandleScale(h.mesh, h.glow, 1.0)); } this.isDragging = false; this.dragTarget = null; this.renderer.domElement.style.cursor = 'default'; } onDragEnter(event) { event.preventDefault(); event.stopPropagation(); if (this.hasImageFile(event)) { this.dropOverlay.classList.add('active'); } } onDragOver(event) { event.preventDefault(); event.stopPropagation(); if (this.hasImageFile(event)) { event.dataTransfer.dropEffect = 'copy'; } } onDragLeave(event) { event.preventDefault(); event.stopPropagation(); // Only hide overlay if leaving the container entirely const rect = this.containerEl.getBoundingClientRect(); const x = event.clientX; const y = event.clientY; if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { this.dropOverlay.classList.remove('active'); } } async onDrop(event) { event.preventDefault(); event.stopPropagation(); this.dropOverlay.classList.remove('active'); const file = this.getImageFile(event); if (file) { await this.uploadFile(file); } } hasImageFile(event) { if (event.dataTransfer?.types) { for (const type of event.dataTransfer.types) { if (type === 'Files') { return true; } } } return false; } getImageFile(event) { const files = event.dataTransfer?.files; if (files && files.length > 0) { const file = files[0]; if (file.type.startsWith('image/')) { return file; } } return null; } async uploadFile(file) { try { const formData = new FormData(); formData.append('image', file); formData.append('overwrite', 'true'); const response = await api.fetchApi('/upload/image', { method: 'POST', body: formData }); if (response.ok) { const result = await response.json(); const filename = result.name; this.state.uploadedImageFile = filename; const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`); this.updateImage(imageUrl); this.notifyStateChange(); } } catch (error) { console.error('Error uploading image:', error); } } onResize() { const w = this.canvasContainer.clientWidth; const h = this.canvasContainer.clientHeight; if (w === 0 || h === 0) return; this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.renderer.setSize(w, h); this.requestRender(); } animate() { this.animationId = requestAnimationFrame(() => this.animate()); this.time += 1; if (this.needsRender || this.time % 30 === 0) { if (this.time % 30 === 0) { this.glowRing.rotation.z += 0.1; } this.renderer.render(this.scene, this.camera); this.needsRender = false; } } requestRender() { this.needsRender = true; } generatePrompt() { const hAngle = this.state.azimuth % 360; let hDirection; if (hAngle < 22.5 || hAngle >= 337.5) hDirection = "front view"; else if (hAngle < 67.5) hDirection = "front-right quarter view"; else if (hAngle < 112.5) hDirection = "right side view"; else if (hAngle < 157.5) hDirection = "back-right quarter view"; else if (hAngle < 202.5) hDirection = "back view"; else if (hAngle < 247.5) hDirection = "back-left quarter view"; else if (hAngle < 292.5) hDirection = "left side view"; else hDirection = "front-left quarter view"; let vDirection; if (this.state.elevation < -30) vDirection = "extreme low-angle shot"; else if (this.state.elevation < -10) vDirection = "low-angle shot"; else if (this.state.elevation < 10) vDirection = "eye-level shot"; else if (this.state.elevation < 40) vDirection = "elevated shot"; else vDirection = "high-angle shot"; let distance; if (this.state.distance < 2) distance = "wide shot"; else if (this.state.distance < 6) distance = "medium shot"; else distance = "close-up"; return ` ${hDirection} ${vDirection} ${distance}`; } updateDisplay() { this.hValueEl.textContent = `${Math.round(this.state.azimuth)}°`; this.vValueEl.textContent = `${Math.round(this.state.elevation)}°`; this.zValueEl.textContent = this.state.distance.toFixed(1); this.promptEl.textContent = this.generatePrompt(); this.syncDropdowns(); } syncDropdowns() { this.azimuthSelect.value = this.findClosestOption(this.state.azimuth, this.AZIMUTH_OPTIONS, true).toString(); this.elevationSelect.value = this.findClosestOption(this.state.elevation, this.ELEVATION_OPTIONS, false).toString(); this.distanceSelect.value = this.findClosestDistanceOption(this.state.distance).toString(); } findClosestOption(value, options, isAzimuth = false) { let closest = options[0].value; let minDiff = Math.abs(value - options[0].value); for (const opt of options) { let diff = Math.abs(value - opt.value); if (isAzimuth) { diff = Math.min(diff, Math.abs(value - opt.value - 360), Math.abs(value - opt.value + 360)); } if (diff < minDiff) { minDiff = diff; closest = opt.value; } } return closest; } findClosestDistanceOption(distance) { if (distance < 2) return 1; if (distance < 6) return 4; return 8; } notifyStateChange() { if (this.onStateChange) { this.onStateChange({ ...this.state }); } } resetToDefaults() { this.state.azimuth = 0; this.state.elevation = 0; this.state.distance = 5.0; this.liveAzimuth = 0; this.liveElevation = 0; this.liveDistance = 5.0; this.updateVisuals(); this.updateDisplay(); this.notifyStateChange(); } async handleFileUpload(event) { const file = event.target.files?.[0]; if (!file) return; try { const formData = new FormData(); formData.append('image', file); formData.append('overwrite', 'true'); const response = await api.fetchApi('/upload/image', { method: 'POST', body: formData }); if (response.ok) { const result = await response.json(); const filename = result.name; this.state.uploadedImageFile = filename; const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`); this.updateImage(imageUrl); this.notifyStateChange(); } } catch (error) { console.error('Error uploading image:', error); } event.target.value = ''; } setState(newState) { if (newState.azimuth !== undefined) { this.state.azimuth = newState.azimuth; this.liveAzimuth = newState.azimuth; } if (newState.elevation !== undefined) { this.state.elevation = newState.elevation; this.liveElevation = newState.elevation; } if (newState.distance !== undefined) { this.state.distance = newState.distance; this.liveDistance = newState.distance; } if (newState.imageUrl !== undefined) { this.state.imageUrl = newState.imageUrl; this.updateImage(newState.imageUrl); } this.updateVisuals(); this.updateDisplay(); } getState() { return { ...this.state }; } getPrompt() { return this.generatePrompt(); } updateImage(url) { if (url) { const img = new Image(); if (!url.startsWith('data:')) { img.crossOrigin = 'anonymous'; } img.onload = () => { if (this.planeMat.map) { this.planeMat.map.dispose(); } const tex = new THREE.Texture(img); tex.colorSpace = THREE.SRGBColorSpace; tex.needsUpdate = true; this.planeMat.map = tex; this.planeMat.color.set(0xffffff); this.planeMat.needsUpdate = true; const ar = img.width / img.height; const maxSize = 1.5; let scaleX, scaleY; if (ar > 1) { scaleX = maxSize; scaleY = maxSize / ar; } else { scaleY = maxSize; scaleX = maxSize * ar; } this.imagePlane.scale.set(scaleX, scaleY, 1); this.imageFrame.scale.set(scaleX, scaleY, 1); this.requestRender(); }; img.onerror = () => { this.planeMat.map = null; this.planeMat.color.set(0xE93D82); this.planeMat.needsUpdate = true; this.requestRender(); }; img.src = url; } else { this.planeMat.map = null; this.planeMat.color.set(0x3a3a4a); this.planeMat.needsUpdate = true; this.imagePlane.scale.set(1, 1, 1); this.imageFrame.scale.set(1, 1, 1); this.requestRender(); } } dispose() { if (this.animationId !== null) { try { window.cancelAnimationFrame(this.animationId); } catch (e) {} this.animationId = null; } try { this.renderer.dispose(); } catch (e) {} try { this.scene.clear(); } catch (e) {} } } // ============== ComfyUI Integration ============== const widgetInstances = new Map(); function createCameraWidget(node) { // Prevent duplicate widget creation if (widgetInstances.has(node.id)) { return; } const container = document.createElement('div'); container.id = `synvow-multiangle-widget-${node.id}`; container.style.cssText = 'width:100%;height:100%;min-height:350px'; const getWidgetValue = (name, defaultValue) => { const widget = node.widgets?.find(w => w.name === name); return widget ? Number(widget.value) : defaultValue; }; const initialState = { azimuth: getWidgetValue('horizontal_angle', 0), elevation: getWidgetValue('vertical_angle', 0), distance: getWidgetValue('zoom', 5.0) }; const widget = node.addDOMWidget('camera_preview', 'synvow-multiangle', container, { getMinHeight: () => 370, hideOnZoom: false, serialize: false }); // Find existing uploaded_image widget or create if not exists let uploadedImageWidget = node.widgets?.find(w => w.name === 'uploaded_image'); if (!uploadedImageWidget) { uploadedImageWidget = node.addWidget('hidden', 'uploaded_image', '', () => {}); uploadedImageWidget.serialize = true; } setTimeout(() => { const cameraWidget = new CameraWidget({ node, container, initialState, onStateChange: (state) => { const hWidget = node.widgets?.find(w => w.name === 'horizontal_angle'); const vWidget = node.widgets?.find(w => w.name === 'vertical_angle'); const zWidget = node.widgets?.find(w => w.name === 'zoom'); if (hWidget) hWidget.value = state.azimuth; if (vWidget) vWidget.value = state.elevation; if (zWidget) zWidget.value = state.distance; if (state.uploadedImageFile) uploadedImageWidget.value = state.uploadedImageFile; app.graph?.setDirtyCanvas(true, true); } }); widgetInstances.set(node.id, cameraWidget); const setupWidgetSync = (widgetName, cam) => { const w = node.widgets?.find(widget => widget.name === widgetName); if (w) { const origCallback = w.callback; w.callback = (value) => { if (origCallback) origCallback.call(w, value); if (widgetName === 'horizontal_angle') cam.setState({ azimuth: Number(value) }); else if (widgetName === 'vertical_angle') cam.setState({ elevation: Number(value) }); else if (widgetName === 'zoom') cam.setState({ distance: Number(value) }); }; } }; setupWidgetSync('horizontal_angle', cameraWidget); setupWidgetSync('vertical_angle', cameraWidget); setupWidgetSync('zoom', cameraWidget); }, 100); widget.onRemove = () => { const cameraWidget = widgetInstances.get(node.id); if (cameraWidget) { cameraWidget.dispose(); widgetInstances.delete(node.id); } }; return { widget }; } app.registerExtension({ name: 'ComfyUI.SynvowMultiangle', nodeCreated(node) { if (node.constructor?.comfyClass !== 'SynvowMultiangleCameraNode') return; const [oldWidth, oldHeight] = node.size; node.setSize([Math.max(oldWidth, 350), Math.max(oldHeight, 520)]); createCameraWidget(node); // Override configure to sync state after workflow load const origConfigure = node.configure?.bind(node); node.configure = function(info) { if (origConfigure) origConfigure(info); // Sync camera widget state after configure (workflow load) setTimeout(() => { const cameraWidget = widgetInstances.get(node.id); if (cameraWidget) { const hWidget = node.widgets?.find(w => w.name === 'horizontal_angle'); const vWidget = node.widgets?.find(w => w.name === 'vertical_angle'); const zWidget = node.widgets?.find(w => w.name === 'zoom'); const uploadWidget = node.widgets?.find(w => w.name === 'uploaded_image'); const newState = {}; if (hWidget) newState.azimuth = Number(hWidget.value); if (vWidget) newState.elevation = Number(vWidget.value); if (zWidget) newState.distance = Number(zWidget.value); cameraWidget.setState(newState); // Restore uploaded image if exists if (uploadWidget?.value) { const filename = uploadWidget.value; const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`); cameraWidget.updateImage(imageUrl); cameraWidget.state.uploadedImageFile = filename; } } }, 150); }; } }); // Listen for node execution to update 3D preview api.addEventListener('executed', (event) => { const detail = event.detail; if (!detail?.node || !detail?.output) return; const nodeId = parseInt(detail.node, 10); const cameraWidget = widgetInstances.get(nodeId); if (!cameraWidget) return; const previewImage = detail.output?.preview_image; if (previewImage?.length > 0 && previewImage[0]) { const p = previewImage[0]; const url = api.apiURL( `/view?filename=${encodeURIComponent(p.filename)}&subfolder=${encodeURIComponent(p.subfolder || '')}&type=${encodeURIComponent(p.type || 'temp')}&t=${Date.now()}` ); cameraWidget.updateImage(url); } }); export { CameraWidget };