1176 lines
41 KiB
JavaScript
1176 lines
41 KiB
JavaScript
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 =>
|
|
`<option value="${opt.value}">${t(opt.key)}</option>`
|
|
).join('');
|
|
const elevationOptionsHtml = this.ELEVATION_OPTIONS.map(opt =>
|
|
`<option value="${opt.value}">${t(opt.key)}</option>`
|
|
).join('');
|
|
const distanceOptionsHtml = this.DISTANCE_OPTIONS.map(opt =>
|
|
`<option value="${opt.value}">${t(opt.key)}</option>`
|
|
).join('');
|
|
|
|
this.container.innerHTML = `
|
|
<div class="synvow-multiangle-container">
|
|
<div class="synvow-multiangle-canvas"></div>
|
|
<div class="synvow-multiangle-drop-overlay">
|
|
<span class="synvow-multiangle-drop-text">${t('dropImageHere')}</span>
|
|
</div>
|
|
<div class="synvow-multiangle-prompt"><sks> front view eye-level shot medium shot</div>
|
|
<div class="synvow-multiangle-dropdowns">
|
|
<div class="synvow-multiangle-dropdown">
|
|
<span class="synvow-multiangle-dropdown-label azimuth">${t('horizontal')}</span>
|
|
<select class="synvow-multiangle-select azimuth">${azimuthOptionsHtml}</select>
|
|
</div>
|
|
<div class="synvow-multiangle-dropdown">
|
|
<span class="synvow-multiangle-dropdown-label elevation">${t('vertical')}</span>
|
|
<select class="synvow-multiangle-select elevation">${elevationOptionsHtml}</select>
|
|
</div>
|
|
<div class="synvow-multiangle-dropdown">
|
|
<span class="synvow-multiangle-dropdown-label distance">${t('zoom')}</span>
|
|
<select class="synvow-multiangle-select distance">${distanceOptionsHtml}</select>
|
|
</div>
|
|
</div>
|
|
<div class="synvow-multiangle-info">
|
|
<div class="synvow-multiangle-param">
|
|
<div class="synvow-multiangle-param-label">${t('horizontalFull')}</div>
|
|
<div class="synvow-multiangle-param-value azimuth">0°</div>
|
|
</div>
|
|
<div class="synvow-multiangle-param">
|
|
<div class="synvow-multiangle-param-label">${t('verticalFull')}</div>
|
|
<div class="synvow-multiangle-param-value elevation">0°</div>
|
|
</div>
|
|
<div class="synvow-multiangle-param">
|
|
<div class="synvow-multiangle-param-label">${t('zoomFull')}</div>
|
|
<div class="synvow-multiangle-param-value zoom">5.0</div>
|
|
</div>
|
|
<input type="file" class="synvow-multiangle-file-input" accept="image/*">
|
|
<button class="synvow-multiangle-upload-btn" title="${t('loadImage')}">📁</button>
|
|
<button class="synvow-multiangle-reset" title="${t('resetToDefaults')}">↺</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 `<sks> ${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 };
|