From ea31d68350aae1a54770732d548a5b7b2cd72b8c Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Jul 2026 21:35:38 +0200 Subject: [PATCH] Interactive camera family: webcam motion pushes physical balls on screen New 'Interattivo Palle' family (interactive.js): frame-differencing motion map from the webcam, ball physics (push-away forces, collisions, beat kicks), palette-tinted rendering with motion mist. Camera is requested lazily and released when switching preset; mouse/touch fallback when unavailable. Excluded from Auto VJ so the camera never turns on by itself. Co-Authored-By: Claude Fable 5 --- main.js | 11 ++ preload.js | 2 + src/effects.js | 9 +- src/interactive.js | 387 +++++++++++++++++++++++++++++++++++++++++++++ src/output.html | 1 + src/output.js | 16 +- src/visualizer.js | 7 + 7 files changed, 429 insertions(+), 4 deletions(-) create mode 100644 src/interactive.js diff --git a/main.js b/main.js index 8bd4f9b..212eca5 100644 --- a/main.js +++ b/main.js @@ -75,6 +75,17 @@ async function ensureMicAccess() { } catch (e) { /* getUserMedia will prompt again if needed */ } } +// Camera permission is requested lazily (only when an interactive preset is +// activated), not at startup: no camera prompt for users who never use it. +ipcMain.handle('cam:ensure', async () => { + if (process.platform !== 'darwin') return true; + try { + const status = systemPreferences.getMediaAccessStatus('camera'); + if (status !== 'granted') return await systemPreferences.askForMediaAccess('camera'); + return true; + } catch (e) { return true; } +}); + app.whenReady().then(async () => { if (process.platform === 'darwin' && app.dock) { try { app.dock.setIcon(path.join(__dirname, 'build', 'icon_1024.png')); } catch (e) { /* non-fatal */ } diff --git a/preload.js b/preload.js index d31e75d..aa7d525 100644 --- a/preload.js +++ b/preload.js @@ -36,5 +36,7 @@ contextBridge.exposeInMainWorld('djv', { exportPlaylist: (data) => ipcRenderer.invoke('playlist:export', data), importPlaylist: () => ipcRenderer.invoke('playlist:import'), peaks: (path, buckets) => ipcRenderer.invoke('audio:peaks', path, buckets), + // Ask macOS for camera permission (interactive family) right before use. + camAccess: () => ipcRenderer.invoke('cam:ensure'), readFile: (path) => ipcRenderer.invoke('file:read', path) }); diff --git a/src/effects.js b/src/effects.js index 1e3cbec..ba7e194 100644 --- a/src/effects.js +++ b/src/effects.js @@ -53,7 +53,11 @@ const FAMILIES = [ { name: 'Griglia Neon', scale: 1.0 }, { name: 'Tunnel Neon', scale: 1.0 }, { name: 'Tempesta Plasma', scale: 1.0 }, - { name: 'Supernova', scale: 1.0 } + { name: 'Supernova', scale: 1.0 }, + // Camera-interactive family (interactive.js): webcam motion pushes physical + // balls around the screen. Excluded from Auto VJ (turning the camera on must + // stay a deliberate user choice). + { name: 'Interattivo Palle', scale: 1.0, interactive: true } ]; // Palettes: low colour (a) -> high colour (b), plus optional hue-cycle/sat. @@ -112,7 +116,8 @@ function makeEffect(fi, pi, vi) { // Always present (0/1 and a mode string) so Object.assign merges in // setEffect can't leave a stale flag behind when switching presets. isFluid: fam.fluid ? 1 : 0, - fluidMode: fam.fluidMode || 'ink' + fluidMode: fam.fluidMode || 'ink', + isInteractive: fam.interactive ? 1 : 0 }; } diff --git a/src/interactive.js b/src/interactive.js new file mode 100644 index 0000000..8c4199f --- /dev/null +++ b/src/interactive.js @@ -0,0 +1,387 @@ +// Interactive camera family: the webcam watches the room, frame-differencing +// builds a motion map, and physical balls on screen get pushed away when a +// person "touches" them (their movement overlaps a ball). Same takeover +// pattern as FluidSim: while an interactive preset is active this engine owns +// the shared WebGL2 canvas. Falls back to mouse/touch interaction when no +// camera is available. +(function () { + +const MAXB = 24; // uniform array size in the shader +const GW = 96, GH = 54; // motion grid resolution (screen-mapped, row 0 = top) + +const VERT = `#version 300 es +in vec2 aPos; out vec2 vUv; +void main(){ vUv = aPos*0.5+0.5; gl_Position = vec4(aPos,0.,1.); }`; + +// One fullscreen pass: dark background + motion mist (so people see where the +// camera detects them) + shaded balls with beat/touch glow halos. +const FRAG = `#version 300 es +precision highp float; +in vec2 vUv; out vec4 frag; +uniform vec4 uBalls[${MAXB}]; // x,y (uv, y up), radius (y units), pulse 0..1 +uniform vec3 uCols[${MAXB}]; +uniform int uNum; +uniform vec3 uColA; uniform vec3 uColB; +uniform sampler2D uMotion; +uniform float uT, uBeat, uBass, uLevel; +uniform vec2 uRes; +void main(){ + float as = uRes.x/max(uRes.y,1.0); + vec2 p = vec2(vUv.x*as, vUv.y); + // Motion grid row 0 is the top of the screen; GL v=0 is the bottom. + float m = texture(uMotion, vec2(vUv.x, 1.0-vUv.y)).r; + vec3 col = uColA*0.10*(0.7+0.5*uLevel)*(1.2-0.8*length(vUv-0.5)); + vec3 mist = mix(uColA, uColB, 0.45) + vec3(0.05); + col += mist*pow(clamp(m,0.0,1.0),1.15)*0.6; + float px = 2.0/uRes.y; // ~2px soft edge in y units + for (int i=0;i<${MAXB};i++){ + if (i>=uNum) break; + vec4 b = uBalls[i]; + vec2 bp = vec2(b.x*as, b.y); + float d = length(p-bp); + float body = smoothstep(b.z, b.z-3.0*px, d); + vec3 bc = uCols[i]; + if (body>0.001){ + vec2 n = (p-bp)/max(b.z,1e-4); + float hl = pow(max(0.0, 1.0-length(n-vec2(-0.42,0.46))*0.85), 4.0); + float shade = 0.50+0.50*max(0.0, 1.0-0.65*length(n+vec2(0.30,-0.30))); + vec3 bcol = bc*shade + vec3(1.0)*hl*0.75; + bcol *= 1.0 + b.w*0.9 + uBeat*0.15; + col = mix(col, bcol, body); + } + float halo = exp(-pow(max(0.0,d-b.z)*(26.0-10.0*b.w),2.0)); + col += bc*halo*(0.10+0.55*b.w+0.12*uBeat); + } + frag = vec4(pow(max(col,0.0), vec3(0.9)), 1.0); +}`; + +// Rotate an RGB colour around the luminance axis (same matrix as CSS +// hue-rotate) so per-ball colours drift with the palette's hueCycle. +function hueRotate(c, a) { + const cs = Math.cos(a), sn = Math.sin(a); + return [ + c[0]*(0.213+cs*0.787-sn*0.213)+c[1]*(0.715-cs*0.715-sn*0.715)+c[2]*(0.072-cs*0.072+sn*0.928), + c[0]*(0.213-cs*0.213+sn*0.143)+c[1]*(0.715+cs*0.285+sn*0.140)+c[2]*(0.072-cs*0.072-sn*0.283), + c[0]*(0.213-cs*0.213-sn*0.787)+c[1]*(0.715-cs*0.715+sn*0.715)+c[2]*(0.072+cs*0.928+sn*0.072) + ]; +} + +class InteractiveSim { + constructor(gl) { + this.gl = gl; + const compile = (type, src) => { + const sh = gl.createShader(type); + gl.shaderSource(sh, src); gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) + throw new Error('Interactive shader: ' + gl.getShaderInfoLog(sh)); + return sh; + }; + const p = gl.createProgram(); + gl.attachShader(p, compile(gl.VERTEX_SHADER, VERT)); + gl.attachShader(p, compile(gl.FRAGMENT_SHADER, FRAG)); + gl.linkProgram(p); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) + throw new Error('Interactive link: ' + gl.getProgramInfoLog(p)); + this.prog = p; + this.vbo = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW); + this.aPos = gl.getAttribLocation(p, 'aPos'); + const U = (n) => gl.getUniformLocation(p, n); + this.u = { uBalls: U('uBalls[0]'), uCols: U('uCols[0]'), uNum: U('uNum'), + uColA: U('uColA'), uColB: U('uColB'), uMotion: U('uMotion'), uT: U('uT'), + uBeat: U('uBeat'), uBass: U('uBass'), uLevel: U('uLevel'), uRes: U('uRes') }; + + // Motion grid + its GPU texture (single channel). + this.grid = new Float32Array(GW*GH); + this.gridU8 = new Uint8Array(GW*GH); + this.motTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.motTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, GW, GH, 0, gl.RED, gl.UNSIGNED_BYTE, this.gridU8); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + + // Camera downscale canvas for frame differencing. + this.vcv = document.createElement('canvas'); + this.vcv.width = GW; this.vcv.height = GH; + this.vctx = this.vcv.getContext('2d', { willReadFrequently: true }); + this.prevGray = null; + this.video = null; + this.camState = 'off'; // off | starting | on | error + this.camErr = ''; + + this.balls = null; + this.ballData = new Float32Array(MAXB*4); + this.colData = new Float32Array(MAXB*3); + this.lastT = 0; + this.prevBeat = 0; + this.pointer = { x: 0, y: 0, t: -1e9 }; + this._bound = null; + } + + // ------------------------------------------------------------- camera + async _startCam() { + if (this.camState !== 'off') return; + this.camState = 'starting'; + try { + if (window.djv && window.djv.camAccess) await window.djv.camAccess(); + const stream = await navigator.mediaDevices.getUserMedia({ + video: { width: { ideal: 640 }, height: { ideal: 360 }, facingMode: 'user' }, + audio: false + }); + const v = document.createElement('video'); + v.srcObject = stream; v.muted = true; v.playsInline = true; + await v.play(); + this.video = v; + this.camState = 'on'; + } catch (e) { + this.camState = 'error'; + this.camErr = e && e.message ? e.message : String(e); + } + } + + // Called when a non-interactive preset takes over: release the camera so + // the green indicator light goes off, and reset for a fresh start later. + suspend() { + if (this.video) { + try { this.video.srcObject.getTracks().forEach(t => t.stop()); } catch (e) {} + this.video = null; + } + this.camState = 'off'; + this.balls = null; + this.grid.fill(0); + this.lastT = 0; + } + + // ------------------------------------------------------------- motion + // Inject a gaussian blob of motion (used by pointer fallback and tests). + splatMotion(px, py, radius, val) { + const cx = px*GW, cy = py*GH, r2 = radius*radius; + const x0 = Math.max(0, Math.floor(cx-radius-1)), x1 = Math.min(GW-1, Math.ceil(cx+radius+1)); + const y0 = Math.max(0, Math.floor(cy-radius-1)), y1 = Math.min(GH-1, Math.ceil(cy+radius+1)); + for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) { + const dx = x-cx, dy = y-cy; + const v = val*Math.exp(-(dx*dx+dy*dy)/r2); + const i = y*GW+x; + if (v > this.grid[i]) this.grid[i] = Math.min(1, v); + } + } + + _updateMotion(dt, nowMs) { + // Decay so touches are brief pushes, not permanent walls. + const k = Math.exp(-dt*6.0); + for (let i = 0; i < this.grid.length; i++) this.grid[i] *= k; + + // Camera frame differencing, mirrored so it behaves like a mirror. + const v = this.video; + if (v && v.readyState >= 2 && v.videoWidth) { + const ctx = this.vctx; + // Cover-fit the camera frame onto the grid, horizontally mirrored. + const s = Math.max(GW/v.videoWidth, GH/v.videoHeight); + const dw = v.videoWidth*s, dh = v.videoHeight*s; + ctx.save(); + ctx.translate(GW, 0); ctx.scale(-1, 1); + ctx.drawImage(v, (GW-dw)/2, (GH-dh)/2, dw, dh); + ctx.restore(); + const img = ctx.getImageData(0, 0, GW, GH).data; + if (!this.prevGray) this.prevGray = new Uint8Array(GW*GH); + const prev = this.prevGray; + const first = this.grayInit !== true; + for (let i = 0, j = 0; i < GW*GH; i++, j += 4) { + const g = (img[j]+img[j+1]*2+img[j+2]) >> 2; + if (!first) { + const d = g > prev[i] ? g-prev[i] : prev[i]-g; + if (d > 16) { + const m = Math.min(1, (d-16)/48); + if (m > this.grid[i]) this.grid[i] = m; + } + } + prev[i] = g; + } + this.grayInit = true; + } + + // Pointer fallback / extra input: recent mouse or touch movement. + if (nowMs - this.pointer.t < 90) + this.splatMotion(this.pointer.x, this.pointer.y, 2.6, 1.0); + + // Upload to the GPU. + for (let i = 0; i < this.grid.length; i++) this.gridU8[i] = (this.grid[i]*255) | 0; + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.motTex); + gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, GW, GH, gl.RED, gl.UNSIGNED_BYTE, this.gridU8); + } + + _bindPointer(canvas) { + if (this._bound === canvas) return; + this._bound = canvas; + const set = (cx, cy) => { + const r = canvas.getBoundingClientRect(); + this.pointer.x = (cx-r.left)/Math.max(1, r.width); + this.pointer.y = (cy-r.top)/Math.max(1, r.height); + this.pointer.t = performance.now(); + }; + canvas.addEventListener('mousemove', (e) => set(e.clientX, e.clientY)); + canvas.addEventListener('touchmove', (e) => { + if (e.touches.length) set(e.touches[0].clientX, e.touches[0].clientY); + }, { passive: true }); + } + + // ------------------------------------------------------------- physics + _initBalls(e, aspect) { + // Variant symmetry picks the ball count (Mirror12 -> a crowd of 22). + const count = e.sym > 0 ? Math.min(MAXB, 10 + e.sym) : 12; + this.balls = []; + for (let i = 0; i < count; i++) { + const r = 0.055 + 0.045*((i*0.618) % 1); + this.balls.push({ + x: 0.12*aspect + Math.random()*(aspect-0.24*aspect), + y: 0.15 + Math.random()*0.7, + vx: (Math.random()-0.5)*0.10, + vy: (Math.random()-0.5)*0.10, + r, pulse: 0 + }); + } + } + + _physics(dt, e, audio, aspect) { + const balls = this.balls, grid = this.grid; + const cw = aspect/GW, ch = 1/GH; + const speed = Math.min(2.5, e.speed || 1); + const dtp = dt*speed; + + for (const b of balls) { + // Push away from nearby camera motion ("touch"). + const R = b.r*2.1; + const gx0 = Math.max(0, Math.floor((b.x-R)/cw)), gx1 = Math.min(GW-1, Math.ceil((b.x+R)/cw)); + // Grid row 0 = screen top; ball y is bottom-up -> flip when indexing. + const gy0 = Math.max(0, Math.floor((1-b.y-R)/ch)), gy1 = Math.min(GH-1, Math.ceil((1-b.y+R)/ch)); + let fx = 0, fy = 0, tot = 0; + for (let gy = gy0; gy <= gy1; gy++) for (let gx = gx0; gx <= gx1; gx++) { + const m = grid[gy*GW+gx]; + if (m < 0.06) continue; + const cx = (gx+0.5)*cw, cy = 1-(gy+0.5)*ch; + let dx = b.x-cx, dy = b.y-cy; + const d = Math.sqrt(dx*dx+dy*dy); + if (d > R || d < 1e-5) continue; + const w = m*(1-d/R); + fx += (dx/d)*w; fy += (dy/d)*w; + tot += w; + } + if (tot > 0.02) { + const fl = Math.sqrt(fx*fx+fy*fy) || 1; + const acc = Math.min(9.0, tot*4.0); + b.vx += (fx/fl)*acc*dtp; + b.vy += (fy/fl)*acc*dtp; + b.pulse = Math.min(1, b.pulse + tot*0.5); + } + + // Gentle wander so the scene never looks frozen. + b.vx += (Math.random()-0.5)*0.05*dtp; + b.vy += (Math.random()-0.5)*0.05*dtp; + + // Damping + speed cap. + const damp = Math.exp(-dt*0.55); + b.vx *= damp; b.vy *= damp; + const sp = Math.sqrt(b.vx*b.vx+b.vy*b.vy); + if (sp > 1.5) { b.vx *= 1.5/sp; b.vy *= 1.5/sp; } + + b.x += b.vx*dtp; b.y += b.vy*dtp; + + // Wall bounce. + if (b.x < b.r) { b.x = b.r; b.vx = Math.abs(b.vx)*0.9; } + if (b.x > aspect-b.r) { b.x = aspect-b.r; b.vx = -Math.abs(b.vx)*0.9; } + if (b.y < b.r) { b.y = b.r; b.vy = Math.abs(b.vy)*0.9; } + if (b.y > 1-b.r) { b.y = 1-b.r; b.vy = -Math.abs(b.vy)*0.9; } + + b.pulse *= Math.exp(-dt*3.0); + } + + // Ball-ball collisions (small N, O(n^2) is fine). + for (let i = 0; i < balls.length; i++) for (let j = i+1; j < balls.length; j++) { + const a = balls[i], c = balls[j]; + let dx = c.x-a.x, dy = c.y-a.y; + const d = Math.sqrt(dx*dx+dy*dy), min = a.r+c.r; + if (d >= min || d < 1e-6) continue; + dx /= d; dy /= d; + const overlap = (min-d)/2; + a.x -= dx*overlap; a.y -= dy*overlap; + c.x += dx*overlap; c.y += dy*overlap; + const rel = (c.vx-a.vx)*dx + (c.vy-a.vy)*dy; + if (rel < 0) { + const imp = -rel*0.92; + a.vx -= dx*imp; a.vy -= dy*imp; + c.vx += dx*imp; c.vy += dy*imp; + } + } + + // Beat: a little kick + glow on every ball. + if (audio.beat > 0.6 && this.prevBeat <= 0.6) { + for (const b of balls) { + b.vx += (Math.random()-0.5)*0.10; + b.vy += (Math.random()-0.5)*0.10; + b.pulse = Math.min(1, b.pulse + 0.30); + } + } + this.prevBeat = audio.beat; + } + + // ------------------------------------------------------------- render + render(timeSec, audio, e, canvas) { + const gl = this.gl; + this._bindPointer(canvas); + this._startCam(); // async, guarded; no-op once on/erroring + + const aspect = canvas.width/Math.max(1, canvas.height); + if (!this.balls || this._sym !== (e.sym|0)) { this._sym = e.sym|0; this._initBalls(e, aspect); } + + const dt = this.lastT ? Math.min(0.045, timeSec-this.lastT) : 0.016; + this.lastT = timeSec; + const nowMs = performance.now(); + + this._updateMotion(dt, nowMs); + this._physics(Math.max(0.001, dt), e, audio, aspect); + + // Pack uniforms: positions as uv (x/aspect), radius stays in y units. + const bd = this.ballData, cd = this.colData, n = this.balls.length; + const hueA = (e.hueBase || 0)*6.2832 + timeSec*(e.hueCycle || 0)*6.2832; + const ca = e.colorA || [0.05, 0, 0.2], cb = e.colorB || [0.2, 1, 1]; + for (let i = 0; i < n; i++) { + const b = this.balls[i]; + bd[i*4] = b.x/aspect; bd[i*4+1] = b.y; + bd[i*4+2] = b.r*(1 + 0.14*(audio.bass || 0) + 0.20*b.pulse); + bd[i*4+3] = b.pulse; + const f = 0.30 + 0.70*((i*0.618) % 1); + const base = [ca[0]+(cb[0]-ca[0])*f, ca[1]+(cb[1]-ca[1])*f, ca[2]+(cb[2]-ca[2])*f]; + // Small per-ball hue offset: variety without losing the palette identity. + const col = hueRotate(base, hueA + (i%5)*0.16 - 0.32); + cd[i*3] = Math.max(0, col[0]); cd[i*3+1] = Math.max(0, col[1]); cd[i*3+2] = Math.max(0, col[2]); + } + + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(this.prog); + gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo); + gl.enableVertexAttribArray(this.aPos); + gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 0, 0); + gl.uniform4fv(this.u.uBalls, bd); + gl.uniform3fv(this.u.uCols, cd); + gl.uniform1i(this.u.uNum, n); + gl.uniform3fv(this.u.uColA, ca); + gl.uniform3fv(this.u.uColB, cb); + gl.uniform1f(this.u.uT, timeSec); + gl.uniform1f(this.u.uBeat, audio.beat || 0); + gl.uniform1f(this.u.uBass, audio.bass || 0); + gl.uniform1f(this.u.uLevel, audio.level || 0); + gl.uniform2f(this.u.uRes, canvas.width, canvas.height); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.motTex); + gl.uniform1i(this.u.uMotion, 0); + gl.drawArrays(gl.TRIANGLES, 0, 3); + } +} + +window.InteractiveSim = InteractiveSim; + +})(); diff --git a/src/output.html b/src/output.html index cea5777..7afa451 100644 --- a/src/output.html +++ b/src/output.html @@ -43,6 +43,7 @@ + diff --git a/src/output.js b/src/output.js index e630d76..df24619 100644 --- a/src/output.js +++ b/src/output.js @@ -496,10 +496,13 @@ function avPick(mood) { if (avMode === 'smart') fams = AV_POOLS[mood] || AV_POOLS.groove; else if (avMode === 'custom' && avFamilies.length) fams = avFamilies; const speedOk = (e) => (mood === 'peak' ? e.speed >= 1.0 : e.speed <= 1.4); - let cands = window.EFFECTS.list.filter(e => (!fams || fams.indexOf(e.familyName) >= 0) && + // Interactive presets are excluded: the Auto VJ must never switch the + // camera on by itself. + let cands = window.EFFECTS.list.filter(e => !e.isInteractive && + (!fams || fams.indexOf(e.familyName) >= 0) && speedOk(e) && avRecent.indexOf(e.name) < 0); // A tight custom list can exhaust the speed filter: retry without it. - if (!cands.length) cands = window.EFFECTS.list.filter(e => + if (!cands.length) cands = window.EFFECTS.list.filter(e => !e.isInteractive && (!fams || fams.indexOf(e.familyName) >= 0) && avRecent.indexOf(e.name) < 0); const e = cands.length ? cands[Math.floor(Math.random() * cands.length)] : window.EFFECTS.list[0]; avRecent = [e.name].concat(avRecent).slice(0, 6); // don't repeat the last few @@ -517,6 +520,7 @@ function avTick(now) { djv.report({ type: 'autoVj', on: true, name: e.name, mood, bpm: audio.bpm || 0 }); } +let camErrReported = false; function frame() { const t = (performance.now() - startTime) / 1000; audio.update(); @@ -524,6 +528,14 @@ function frame() { avTick(performance.now()); viz.render(t * speed, a); + // Interactive family: tell the control panel if the camera didn't start + // (permission denied / missing device). Mouse interaction still works. + if (viz.inter && viz.inter.camState === 'error' && !camErrReported) { + camErrReported = true; + djv.report({ type: 'error', message: 'Camera non disponibile (' + viz.inter.camErr + + ') — l’effetto Interattivo funziona comunque con il mouse sull’output' }); + } + // Report beat rising-edges so the control window can auto-cycle effects. if (a.beat > 0.6 && prevBeat <= 0.6) djv.report({ type: 'beat' }); prevBeat = a.beat; diff --git a/src/visualizer.js b/src/visualizer.js index be1857a..c5df344 100644 --- a/src/visualizer.js +++ b/src/visualizer.js @@ -94,6 +94,13 @@ class Visualizer { render(timeSec, audio) { const gl = this.gl, u = this.u, e = this.effect; + // Release the camera as soon as a non-interactive preset takes over. + if (this.inter && !e.isInteractive) this.inter.suspend(); + if (e.isInteractive && window.InteractiveSim) { + if (!this.inter) this.inter = new window.InteractiveSim(gl); + this.inter.render(timeSec, audio, e, this.canvas); + return; + } if (e.isFluid && window.FluidSim) { if (!this.fluid) this.fluid = new window.FluidSim(gl); this.fluid.render(timeSec, audio, e, this.canvas);