@viji-dev/core 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/docs-api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export const docsApi = {
2
2
  "version": "1.1.0",
3
- "coreVersion": "0.4.0",
4
- "generatedAt": "2026-04-28T17:26:10.039Z",
3
+ "coreVersion": "0.4.1",
4
+ "generatedAt": "2026-04-28T20:06:49.241Z",
5
5
  "navigation": [
6
6
  {
7
7
  "id": "getting-started",
@@ -2519,15 +2519,16 @@ export const docsApi = {
2519
2519
  "content": [
2520
2520
  {
2521
2521
  "type": "text",
2522
- "markdown": "# Parameter Categories\r\n\r\nCategories let you tag parameters so they're only visible when the corresponding capability is active. An \"Audio Pulse\" [`viji.slider()`](../slider/) is useless if no audio source is connected: with `category: 'audio'`, it automatically appears when audio is available and hides when it's not.\r\n\r\n## The Four Categories\r\n\r\n| Category | Visible When | Use For |\r\n|---|---|---|\r\n| `'general'` | Always | Colors, sizes, speeds, shapes: anything that works without external input |\r\n| `'audio'` | Audio source is connected | Volume scaling, beat reactivity, frequency controls |\r\n| `'video'` | Video/camera source is connected | Video opacity, CV sensitivity, segmentation controls |\r\n| `'interaction'` | User interaction is enabled | Mouse effects, keyboard bindings, touch sensitivity |\r\n\r\n## Usage\r\n\r\nSet the `category` config key on any parameter:\r\n\r\n```javascript\r\nconst baseColor = viji.color('#4488ff', { label: 'Base Color', category: 'general' });\r\nconst pulseAmount = viji.slider(0.3, { min: 0, max: 1, label: 'Audio Pulse', category: 'audio' });\r\nconst showMouse = viji.toggle(true, { label: 'Mouse Trail', category: 'interaction' });\r\n```\r\n\r\n- `baseColor` ([`viji.color()`](../color/)) is always visible: it has no external dependency.\r\n- `pulseAmount` ([`viji.slider()`](../slider/)) only appears when the host connects an audio source.\r\n- `showMouse` ([`viji.toggle()`](../toggle/)) only appears when user interaction is enabled.\r\n\r\nIf you omit `category`, it defaults to `'general'` (always visible).\r\n\r\n## How It Works\r\n\r\n1. The artist sets `category` on each parameter during scene initialization.\r\n2. When the host application requests parameters, Viji filters them based on the current `CoreCapabilities`:\r\n - `hasAudio`: is an audio stream connected?\r\n - `hasVideo`: is a video/camera stream connected?\r\n - `hasInteraction`: is user interaction enabled?\r\n - `hasGeneral`: always `true`.\r\n3. Only parameters matching active capabilities are sent to the UI.\r\n\r\n> [!NOTE]\r\n> Categories filter at both the **group level** and the **individual parameter level**. If a group's category doesn't match, the entire group is hidden. If individual parameters within a visible group have non-matching categories, those parameters are hidden while the group remains visible.\r\n\r\n## Live Example\r\n\r\nThis scene has parameters in three categories: `general` (always visible), `audio` (needs audio), and `interaction` (needs mouse):"
2522
+ "markdown": "# Parameter Categories\r\n\r\nCategories let you tag parameters so they're only visible when the corresponding capability is active. An \"Audio Pulse\" [`viji.slider()`](../slider/) is useless if no audio source is connected: with `category: 'audio'`, it automatically appears when audio is available and hides when it's not.\r\n\r\n## The Four Categories\r\n\r\n| Category | Visible When | Use For |\r\n|---|---|---|\r\n| `'general'` | Always | Colors, sizes, speeds, shapes: anything that works without external input |\r\n| `'audio'` | Audio source is connected | Volume scaling, beat reactivity, frequency controls |\r\n| `'video'` | Video/camera source is connected | Video opacity, CV sensitivity, segmentation controls |\r\n| `'interaction'` | User interaction is enabled | Mouse effects, keyboard bindings, touch sensitivity |\r\n\r\n## Usage\r\n\r\nSet the `category` config key on any parameter:\r\n\r\n```javascript\r\nconst baseColor = viji.color('#4488ff', { label: 'Base Color', category: 'general' });\r\nconst pulseAmount = viji.slider(0.3, { min: 0, max: 1, label: 'Audio Pulse', category: 'audio' });\r\nconst videoOpacity = viji.slider(0.6, { min: 0, max: 1, label: 'Video Opacity', category: 'video' });\r\nconst showMouse = viji.toggle(true, { label: 'Mouse Trail', category: 'interaction' });\r\n```\r\n\r\n- `baseColor` ([`viji.color()`](../color/)) is always visible: it has no external dependency.\r\n- `pulseAmount` ([`viji.slider()`](../slider/)) only appears when the host connects an audio source.\r\n- `videoOpacity` ([`viji.slider()`](../slider/)) only appears when a video/camera source is connected.\r\n- `showMouse` ([`viji.toggle()`](../toggle/)) only appears when user interaction is enabled.\r\n\r\nIf you omit `category`, it defaults to `'general'` (always visible).\r\n\r\n## How It Works\r\n\r\n1. The artist sets `category` on each parameter during scene initialization.\r\n2. When the host application requests parameters, Viji filters them based on the current `CoreCapabilities`:\r\n - `hasAudio`: is an audio stream connected?\r\n - `hasVideo`: is a video/camera stream connected?\r\n - `hasInteraction`: is user interaction enabled?\r\n - `hasGeneral`: always `true`.\r\n3. Only parameters matching active capabilities are sent to the UI.\r\n\r\n> [!NOTE]\r\n> Categories filter at both the **group level** and the **individual parameter level**. If a group's category doesn't match, the entire group is hidden. If individual parameters within a visible group have non-matching categories, those parameters are hidden while the group remains visible.\r\n\r\n## Live Example\r\n\r\nThis scene has parameters in all four categories: `general` (always visible), `audio` (needs audio), `video` (needs camera), and `interaction` (needs mouse). Each control reveals itself as you connect the corresponding capability:"
2523
2523
  },
2524
2524
  {
2525
2525
  "type": "live-example",
2526
2526
  "title": "Parameter Categories",
2527
- "sceneCode": "const baseColor = viji.color('#4488ff', { label: 'Base Color', category: 'general' });\r\nconst pulseAmount = viji.slider(0.3, { min: 0, max: 1, step: 0.01, label: 'Audio Pulse', category: 'audio' });\r\nconst showMouse = viji.toggle(true, { label: 'Mouse Trail', category: 'interaction' });\r\n\r\nlet angle = 0;\r\nlet mouseTrailX = 0;\r\nlet mouseTrailY = 0;\r\n\r\nfunction render(viji) {\r\n const ctx = viji.useContext('2d');\r\n const w = viji.width;\r\n const h = viji.height;\r\n\r\n ctx.fillStyle = 'rgba(10, 10, 30, 0.15)';\r\n ctx.fillRect(0, 0, w, h);\r\n\r\n angle += viji.deltaTime;\r\n\r\n const r = parseInt(baseColor.value.slice(1, 3), 16);\r\n const g = parseInt(baseColor.value.slice(3, 5), 16);\r\n const b = parseInt(baseColor.value.slice(5, 7), 16);\r\n\r\n let pulse = 0;\r\n if (viji.audio.isConnected) {\r\n pulse = viji.audio.volume.current * pulseAmount.value;\r\n }\r\n\r\n const baseR = Math.min(w, h) * (0.1 + pulse * 0.15);\r\n const cx = w / 2 + Math.cos(angle) * w * 0.2;\r\n const cy = h / 2 + Math.sin(angle * 0.7) * h * 0.2;\r\n\r\n ctx.beginPath();\r\n ctx.arc(cx, cy, baseR, 0, Math.PI * 2);\r\n ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;\r\n ctx.fill();\r\n\r\n if (showMouse.value && viji.mouse.isInCanvas) {\r\n mouseTrailX += (viji.mouse.x - mouseTrailX) * 0.1;\r\n mouseTrailY += (viji.mouse.y - mouseTrailY) * 0.1;\r\n ctx.beginPath();\r\n ctx.arc(mouseTrailX, mouseTrailY, Math.min(w, h) * 0.02, 0, Math.PI * 2);\r\n ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';\r\n ctx.fill();\r\n }\r\n}\r\n",
2527
+ "sceneCode": "const baseColor = viji.color('#4488ff', { label: 'Base Color', category: 'general' });\r\nconst pulseAmount = viji.slider(0.3, { min: 0, max: 1, step: 0.01, label: 'Audio Pulse', category: 'audio' });\r\nconst videoOpacity = viji.slider(0.6, { min: 0, max: 1, step: 0.01, label: 'Video Opacity', category: 'video' });\r\nconst showMouse = viji.toggle(true, { label: 'Mouse Trail', category: 'interaction' });\r\n\r\nlet angle = 0;\r\nlet mouseTrailX = 0;\r\nlet mouseTrailY = 0;\r\n\r\nfunction render(viji) {\r\n const ctx = viji.useContext('2d');\r\n const w = viji.width;\r\n const h = viji.height;\r\n\r\n if (viji.video.isConnected && viji.video.currentFrame && videoOpacity.value > 0) {\r\n ctx.fillStyle = '#000';\r\n ctx.fillRect(0, 0, w, h);\r\n ctx.globalAlpha = videoOpacity.value;\r\n ctx.drawImage(viji.video.currentFrame, 0, 0, w, h);\r\n ctx.globalAlpha = 1;\r\n } else {\r\n ctx.fillStyle = 'rgba(10, 10, 30, 0.15)';\r\n ctx.fillRect(0, 0, w, h);\r\n }\r\n\r\n angle += viji.deltaTime;\r\n\r\n const r = parseInt(baseColor.value.slice(1, 3), 16);\r\n const g = parseInt(baseColor.value.slice(3, 5), 16);\r\n const b = parseInt(baseColor.value.slice(5, 7), 16);\r\n\r\n let pulse = 0;\r\n if (viji.audio.isConnected) {\r\n pulse = viji.audio.volume.current * pulseAmount.value;\r\n }\r\n\r\n const baseR = Math.min(w, h) * (0.1 + pulse * 0.15);\r\n const cx = w / 2 + Math.cos(angle) * w * 0.2;\r\n const cy = h / 2 + Math.sin(angle * 0.7) * h * 0.2;\r\n\r\n ctx.beginPath();\r\n ctx.arc(cx, cy, baseR, 0, Math.PI * 2);\r\n ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;\r\n ctx.fill();\r\n\r\n if (showMouse.value && viji.mouse.isInCanvas) {\r\n mouseTrailX += (viji.mouse.x - mouseTrailX) * 0.1;\r\n mouseTrailY += (viji.mouse.y - mouseTrailY) * 0.1;\r\n ctx.beginPath();\r\n ctx.arc(mouseTrailX, mouseTrailY, Math.min(w, h) * 0.02, 0, Math.PI * 2);\r\n ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';\r\n ctx.fill();\r\n }\r\n}\r\n",
2528
2528
  "sceneFile": "categories-demo.scene.js",
2529
2529
  "capabilities": {
2530
2530
  "audio": true,
2531
+ "video": true,
2531
2532
  "interaction": true
2532
2533
  }
2533
2534
  },
@@ -2572,7 +2573,10 @@ export const docsApi = {
2572
2573
  "type": "live-example",
2573
2574
  "title": "Audio-Reactive Circle",
2574
2575
  "sceneCode": "function render(viji) {\r\n const ctx = viji.useContext('2d');\r\n const w = viji.width;\r\n const h = viji.height;\r\n\r\n ctx.fillStyle = '#111';\r\n ctx.fillRect(0, 0, w, h);\r\n\r\n if (!viji.audio.isConnected) {\r\n ctx.fillStyle = '#555';\r\n ctx.font = `${Math.min(w, h) * 0.04}px sans-serif`;\r\n ctx.textAlign = 'center';\r\n ctx.fillText('Waiting for audio...', w / 2, h / 2);\r\n return;\r\n }\r\n\r\n const vol = viji.audio.volume.smoothed;\r\n const r = Math.min(w, h) * 0.1 + vol * Math.min(w, h) * 0.3;\r\n\r\n ctx.beginPath();\r\n ctx.arc(w / 2, h / 2, r, 0, Math.PI * 2);\r\n ctx.fillStyle = `hsl(${200 + vol * 160}, 80%, 60%)`;\r\n ctx.fill();\r\n}\r\n",
2575
- "sceneFile": "audio-overview.scene.js"
2576
+ "sceneFile": "audio-overview.scene.js",
2577
+ "capabilities": {
2578
+ "audio": true
2579
+ }
2576
2580
  },
2577
2581
  {
2578
2582
  "type": "text",
@@ -2620,7 +2624,10 @@ export const docsApi = {
2620
2624
  "type": "live-example",
2621
2625
  "title": "Connection State",
2622
2626
  "sceneCode": "function render(viji) {\r\n const ctx = viji.useContext('2d');\r\n const w = viji.width;\r\n const h = viji.height;\r\n\r\n ctx.fillStyle = '#111';\r\n ctx.fillRect(0, 0, w, h);\r\n\r\n const fontSize = Math.min(w, h) * 0.035;\r\n ctx.font = `${fontSize}px sans-serif`;\r\n ctx.textAlign = 'center';\r\n\r\n if (!viji.audio.isConnected) {\r\n const pulse = 0.4 + Math.sin(viji.time * 2) * 0.15;\r\n ctx.fillStyle = `rgba(255, 255, 255, ${pulse})`;\r\n ctx.fillText('Waiting for audio stream...', w / 2, h / 2 - fontSize);\r\n ctx.fillStyle = '#444';\r\n ctx.fillText('Connect a microphone or audio source', w / 2, h / 2 + fontSize);\r\n return;\r\n }\r\n\r\n const vol = viji.audio.volume.smoothed;\r\n const barW = w * 0.6;\r\n const barH = Math.min(w, h) * 0.06;\r\n const barX = (w - barW) / 2;\r\n const barY = h / 2 - barH / 2;\r\n\r\n ctx.fillStyle = '#222';\r\n ctx.fillRect(barX, barY, barW, barH);\r\n ctx.fillStyle = '#4CAF50';\r\n ctx.fillRect(barX, barY, barW * vol, barH);\r\n\r\n ctx.fillStyle = '#aaa';\r\n ctx.fillText('Audio connected: volume: ' + vol.toFixed(2), w / 2, barY - fontSize);\r\n}\r\n",
2623
- "sceneFile": "connection-demo.scene.js"
2627
+ "sceneFile": "connection-demo.scene.js",
2628
+ "capabilities": {
2629
+ "audio": true
2630
+ }
2624
2631
  },
2625
2632
  {
2626
2633
  "type": "text",
@@ -2663,7 +2670,10 @@ export const docsApi = {
2663
2670
  "type": "live-example",
2664
2671
  "title": "Volume Meters",
2665
2672
  "sceneCode": "function render(viji) {\r\n const ctx = viji.useContext('2d');\r\n const w = viji.width;\r\n const h = viji.height;\r\n\r\n ctx.fillStyle = '#111';\r\n ctx.fillRect(0, 0, w, h);\r\n\r\n if (!viji.audio.isConnected) {\r\n ctx.fillStyle = '#555';\r\n ctx.font = `${Math.min(w, h) * 0.04}px sans-serif`;\r\n ctx.textAlign = 'center';\r\n ctx.fillText('Waiting for audio...', w / 2, h / 2);\r\n return;\r\n }\r\n\r\n const vol = viji.audio.volume;\r\n const barH = h * 0.06;\r\n const gap = barH * 0.5;\r\n const barW = w * 0.7;\r\n const x = (w - barW) / 2;\r\n let y = h * 0.3;\r\n const fontSize = barH * 0.7;\r\n\r\n ctx.font = `${fontSize}px sans-serif`;\r\n ctx.textAlign = 'left';\r\n\r\n // Current (instant RMS)\r\n ctx.fillStyle = '#4CAF50';\r\n ctx.fillRect(x, y, barW * vol.current, barH);\r\n ctx.fillStyle = '#aaa';\r\n ctx.fillText('current: ' + vol.current.toFixed(3), x, y - 4);\r\n y += barH + gap;\r\n\r\n // Peak\r\n ctx.fillStyle = '#FF9800';\r\n ctx.fillRect(x, y, barW * vol.peak, barH);\r\n ctx.fillStyle = '#aaa';\r\n ctx.fillText('peak: ' + vol.peak.toFixed(3), x, y - 4);\r\n y += barH + gap;\r\n\r\n // Smoothed\r\n ctx.fillStyle = '#2196F3';\r\n ctx.fillRect(x, y, barW * vol.smoothed, barH);\r\n ctx.fillStyle = '#aaa';\r\n ctx.fillText('smoothed: ' + vol.smoothed.toFixed(3), x, y - 4);\r\n}\r\n",
2666
- "sceneFile": "volume-demo.scene.js"
2673
+ "sceneFile": "volume-demo.scene.js",
2674
+ "capabilities": {
2675
+ "audio": true
2676
+ }
2667
2677
  },
2668
2678
  {
2669
2679
  "type": "text",
@@ -2716,7 +2726,10 @@ export const docsApi = {
2716
2726
  "type": "live-example",
2717
2727
  "title": "Frequency Band Bars",
2718
2728
  "sceneCode": "function render(viji) {\r\n const ctx = viji.useContext('2d');\r\n const w = viji.width;\r\n const h = viji.height;\r\n\r\n ctx.fillStyle = '#111';\r\n ctx.fillRect(0, 0, w, h);\r\n\r\n if (!viji.audio.isConnected) {\r\n ctx.fillStyle = '#555';\r\n ctx.font = `${Math.min(w, h) * 0.04}px sans-serif`;\r\n ctx.textAlign = 'center';\r\n ctx.fillText('Waiting for audio...', w / 2, h / 2);\r\n return;\r\n }\r\n\r\n const bands = viji.audio.bands;\r\n const names = ['low', 'lowMid', 'mid', 'highMid', 'high'];\r\n const colors = ['#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#3498db'];\r\n const barW = w / names.length;\r\n\r\n for (let i = 0; i < names.length; i++) {\r\n const instant = bands[names[i]];\r\n const smoothed = bands[names[i] + 'Smoothed'];\r\n const x = i * barW;\r\n\r\n ctx.fillStyle = colors[i] + '44';\r\n ctx.fillRect(x + 2, h - smoothed * h, barW - 4, smoothed * h);\r\n\r\n ctx.fillStyle = colors[i];\r\n ctx.fillRect(x + 2, h - instant * h, barW - 4, instant * h);\r\n }\r\n\r\n ctx.fillStyle = '#aaa';\r\n ctx.font = `${Math.min(w, h) * 0.03}px sans-serif`;\r\n ctx.textAlign = 'center';\r\n for (let i = 0; i < names.length; i++) {\r\n ctx.fillText(names[i], i * barW + barW / 2, h - 8);\r\n }\r\n}\r\n",
2719
- "sceneFile": "bands-demo.scene.js"
2729
+ "sceneFile": "bands-demo.scene.js",
2730
+ "capabilities": {
2731
+ "audio": true
2732
+ }
2720
2733
  },
2721
2734
  {
2722
2735
  "type": "text",
@@ -2784,7 +2797,10 @@ export const docsApi = {
2784
2797
  "type": "live-example",
2785
2798
  "title": "Beat Pulses",
2786
2799
  "sceneCode": "let hue = 0;\r\n\r\nfunction render(viji) {\r\n const ctx = viji.useContext('2d');\r\n const w = viji.width;\r\n const h = viji.height;\r\n\r\n if (!viji.audio.isConnected) {\r\n ctx.fillStyle = '#111';\r\n ctx.fillRect(0, 0, w, h);\r\n ctx.fillStyle = '#555';\r\n ctx.font = `${Math.min(w, h) * 0.04}px sans-serif`;\r\n ctx.textAlign = 'center';\r\n ctx.fillText('Waiting for audio...', w / 2, h / 2);\r\n return;\r\n }\r\n\r\n const beat = viji.audio.beat;\r\n\r\n if (beat.triggers.kick) {\r\n hue = (hue + 30) % 360;\r\n }\r\n\r\n ctx.fillStyle = `hsla(${hue}, 70%, 50%, 0.08)`;\r\n ctx.fillRect(0, 0, w, h);\r\n\r\n const cx = w / 2;\r\n const cy = h / 2;\r\n const baseR = Math.min(w, h) * 0.08;\r\n\r\n // Kick pulse\r\n ctx.beginPath();\r\n ctx.arc(cx - w * 0.2, cy, baseR + beat.kick * baseR * 2, 0, Math.PI * 2);\r\n ctx.fillStyle = `rgba(231, 76, 60, ${0.3 + beat.kick * 0.7})`;\r\n ctx.fill();\r\n\r\n // Snare pulse\r\n ctx.beginPath();\r\n ctx.arc(cx, cy, baseR + beat.snare * baseR * 1.5, 0, Math.PI * 2);\r\n ctx.fillStyle = `rgba(241, 196, 15, ${0.3 + beat.snare * 0.7})`;\r\n ctx.fill();\r\n\r\n // Hat pulse\r\n ctx.beginPath();\r\n ctx.arc(cx + w * 0.2, cy, baseR + beat.hat * baseR, 0, Math.PI * 2);\r\n ctx.fillStyle = `rgba(52, 152, 219, ${0.3 + beat.hat * 0.7})`;\r\n ctx.fill();\r\n\r\n // Particle spawn on any trigger\r\n if (beat.triggers.any) {\r\n const x = Math.random() * w;\r\n const y = Math.random() * h;\r\n const r = Math.min(w, h) * (0.02 + Math.random() * 0.06);\r\n ctx.beginPath();\r\n ctx.arc(x, y, r, 0, Math.PI * 2);\r\n ctx.fillStyle = `hsl(${hue}, 80%, 60%)`;\r\n ctx.fill();\r\n }\r\n}\r\n",
2787
- "sceneFile": "beat-demo.scene.js"
2800
+ "sceneFile": "beat-demo.scene.js",
2801
+ "capabilities": {
2802
+ "audio": true
2803
+ }
2788
2804
  },
2789
2805
  {
2790
2806
  "type": "text",
@@ -2827,7 +2843,10 @@ export const docsApi = {
2827
2843
  "type": "live-example",
2828
2844
  "title": "Spectral Features",
2829
2845
  "sceneCode": "function render(viji) {\r\n const ctx = viji.useContext('2d');\r\n const w = viji.width;\r\n const h = viji.height;\r\n\r\n ctx.fillStyle = '#111';\r\n ctx.fillRect(0, 0, w, h);\r\n\r\n if (!viji.audio.isConnected) {\r\n ctx.fillStyle = '#555';\r\n ctx.font = `${Math.min(w, h) * 0.04}px sans-serif`;\r\n ctx.textAlign = 'center';\r\n ctx.fillText('Waiting for audio...', w / 2, h / 2);\r\n return;\r\n }\r\n\r\n const brightness = viji.audio.spectral.brightness;\r\n const flatness = viji.audio.spectral.flatness;\r\n\r\n const hue = 20 + brightness * 200;\r\n const sat = 30 + (1 - flatness) * 60;\r\n\r\n const r = Math.min(w, h) * 0.25;\r\n ctx.beginPath();\r\n ctx.arc(w / 2, h / 2, r, 0, Math.PI * 2);\r\n ctx.fillStyle = `hsl(${hue}, ${sat}%, 55%)`;\r\n ctx.fill();\r\n\r\n ctx.fillStyle = '#aaa';\r\n ctx.font = `${Math.min(w, h) * 0.03}px sans-serif`;\r\n ctx.textAlign = 'center';\r\n ctx.fillText(\r\n `brightness: ${brightness.toFixed(2)} flatness: ${flatness.toFixed(2)}`,\r\n w / 2,\r\n h * 0.88\r\n );\r\n}\r\n",
2830
- "sceneFile": "spectral-demo.scene.js"
2846
+ "sceneFile": "spectral-demo.scene.js",
2847
+ "capabilities": {
2848
+ "audio": true
2849
+ }
2831
2850
  },
2832
2851
  {
2833
2852
  "type": "text",
@@ -2875,7 +2894,10 @@ export const docsApi = {
2875
2894
  "type": "live-example",
2876
2895
  "title": "Spectrum Visualizer",
2877
2896
  "sceneCode": "function render(viji) {\r\n const ctx = viji.useContext('2d');\r\n const w = viji.width;\r\n const h = viji.height;\r\n\r\n ctx.fillStyle = '#111';\r\n ctx.fillRect(0, 0, w, h);\r\n\r\n if (!viji.audio.isConnected) {\r\n ctx.fillStyle = '#555';\r\n ctx.font = `${Math.min(w, h) * 0.04}px sans-serif`;\r\n ctx.textAlign = 'center';\r\n ctx.fillText('Waiting for audio...', w / 2, h / 2);\r\n return;\r\n }\r\n\r\n const fft = viji.audio.getFrequencyData();\r\n if (fft.length === 0) return;\r\n\r\n const binCount = fft.length;\r\n const bars = 64;\r\n const logMax = Math.log(binCount);\r\n\r\n for (let i = 0; i < bars; i++) {\r\n const logStart = Math.exp((i / bars) * logMax);\r\n const logEnd = Math.exp(((i + 1) / bars) * logMax);\r\n const startBin = Math.floor(logStart);\r\n const endBin = Math.min(Math.floor(logEnd), binCount - 1);\r\n\r\n let sum = 0;\r\n let count = 0;\r\n for (let b = startBin; b <= endBin; b++) {\r\n sum += fft[b];\r\n count++;\r\n }\r\n const value = count > 0 ? (sum / count) / 255 : 0;\r\n\r\n const barW = w / bars;\r\n const barH = value * h * 0.9;\r\n const hue = (i / bars) * 280;\r\n ctx.fillStyle = `hsl(${hue}, 70%, ${35 + value * 35}%)`;\r\n ctx.fillRect(i * barW + 1, h - barH, barW - 2, barH);\r\n }\r\n}\r\n",
2878
- "sceneFile": "frequency-data-demo.scene.js"
2897
+ "sceneFile": "frequency-data-demo.scene.js",
2898
+ "capabilities": {
2899
+ "audio": true
2900
+ }
2879
2901
  },
2880
2902
  {
2881
2903
  "type": "text",
@@ -2923,7 +2945,10 @@ export const docsApi = {
2923
2945
  "type": "live-example",
2924
2946
  "title": "Oscilloscope",
2925
2947
  "sceneCode": "function render(viji) {\r\n const ctx = viji.useContext('2d');\r\n const w = viji.width;\r\n const h = viji.height;\r\n\r\n ctx.fillStyle = '#111';\r\n ctx.fillRect(0, 0, w, h);\r\n\r\n if (!viji.audio.isConnected) {\r\n ctx.fillStyle = '#555';\r\n ctx.font = `${Math.min(w, h) * 0.04}px sans-serif`;\r\n ctx.textAlign = 'center';\r\n ctx.fillText('Waiting for audio...', w / 2, h / 2);\r\n return;\r\n }\r\n\r\n const waveform = viji.audio.getWaveform();\r\n if (waveform.length === 0) return;\r\n\r\n // Oscilloscope line\r\n ctx.strokeStyle = '#4CAF50';\r\n ctx.lineWidth = 2;\r\n ctx.beginPath();\r\n\r\n const samples = waveform.length;\r\n for (let i = 0; i < samples; i++) {\r\n const x = (i / samples) * w;\r\n const y = h / 2 + waveform[i] * h * 0.4;\r\n if (i === 0) ctx.moveTo(x, y);\r\n else ctx.lineTo(x, y);\r\n }\r\n\r\n ctx.stroke();\r\n\r\n // Center line\r\n ctx.strokeStyle = '#333';\r\n ctx.lineWidth = 1;\r\n ctx.beginPath();\r\n ctx.moveTo(0, h / 2);\r\n ctx.lineTo(w, h / 2);\r\n ctx.stroke();\r\n}\r\n",
2926
- "sceneFile": "waveform-demo.scene.js"
2948
+ "sceneFile": "waveform-demo.scene.js",
2949
+ "capabilities": {
2950
+ "audio": true
2951
+ }
2927
2952
  },
2928
2953
  {
2929
2954
  "type": "text",
@@ -5224,15 +5249,16 @@ export const docsApi = {
5224
5249
  "content": [
5225
5250
  {
5226
5251
  "type": "text",
5227
- "markdown": "# Parameter Categories\r\n\r\nCategories let you tag parameters so they're only visible when the corresponding capability is active. An \"Audio Pulse\" [`viji.slider()`](../slider/) is useless if no audio source is connected: with `category: 'audio'`, it automatically appears when audio is available and hides when it's not.\r\n\r\n## The Four Categories\r\n\r\n| Category | Visible When | Use For |\r\n|---|---|---|\r\n| `'general'` | Always | Colors, sizes, speeds, shapes: anything that works without external input |\r\n| `'audio'` | Audio source is connected | Volume scaling, beat reactivity, frequency controls |\r\n| `'video'` | Video/camera source is connected | Video opacity, CV sensitivity, segmentation controls |\r\n| `'interaction'` | User interaction is enabled | Mouse effects, keyboard bindings, touch sensitivity |\r\n\r\n## Usage\r\n\r\n```javascript\r\n// @renderer p5\r\n\r\nconst baseColor = viji.color('#4488ff', { label: 'Base Color', category: 'general' });\r\nconst pulseAmount = viji.slider(0.3, { min: 0, max: 1, label: 'Audio Pulse', category: 'audio' });\r\nconst showMouse = viji.toggle(true, { label: 'Mouse Dot', category: 'interaction' });\r\n```\r\n\r\n- `baseColor` ([`viji.color()`](../color/)) is always visible.\r\n- `pulseAmount` ([`viji.slider()`](../slider/)) only appears when audio is connected.\r\n- `showMouse` ([`viji.toggle()`](../toggle/)) only appears when interaction is enabled.\r\n\r\nIf you omit `category`, it defaults to `'general'` (always visible).\r\n\r\n## How It Works\r\n\r\n1. The artist sets `category` on each parameter during scene initialization.\r\n2. When the host application requests parameters, Viji filters them based on the current `CoreCapabilities`:\r\n - `hasAudio`: is an audio stream connected?\r\n - `hasVideo`: is a video/camera stream connected?\r\n - `hasInteraction`: is user interaction enabled?\r\n - `hasGeneral`: always `true`.\r\n3. Only parameters matching active capabilities are sent to the UI.\r\n\r\n> [!NOTE]\r\n> Categories filter at both the **group level** and the **individual parameter level**. If a group's category doesn't match, the entire group is hidden. If individual parameters within a visible group have non-matching categories, those parameters are hidden while the group remains visible.\r\n\r\n## Live Example\r\n\r\nParameters in three categories: `general` (always visible), `audio` (needs audio), and `interaction` (needs mouse):"
5252
+ "markdown": "# Parameter Categories\r\n\r\nCategories let you tag parameters so they're only visible when the corresponding capability is active. An \"Audio Pulse\" [`viji.slider()`](../slider/) is useless if no audio source is connected: with `category: 'audio'`, it automatically appears when audio is available and hides when it's not.\r\n\r\n## The Four Categories\r\n\r\n| Category | Visible When | Use For |\r\n|---|---|---|\r\n| `'general'` | Always | Colors, sizes, speeds, shapes: anything that works without external input |\r\n| `'audio'` | Audio source is connected | Volume scaling, beat reactivity, frequency controls |\r\n| `'video'` | Video/camera source is connected | Video opacity, CV sensitivity, segmentation controls |\r\n| `'interaction'` | User interaction is enabled | Mouse effects, keyboard bindings, touch sensitivity |\r\n\r\n## Usage\r\n\r\n```javascript\r\n// @renderer p5\r\n\r\nconst baseColor = viji.color('#4488ff', { label: 'Base Color', category: 'general' });\r\nconst pulseAmount = viji.slider(0.3, { min: 0, max: 1, label: 'Audio Pulse', category: 'audio' });\r\nconst videoOpacity = viji.slider(0.6, { min: 0, max: 1, label: 'Video Opacity', category: 'video' });\r\nconst showMouse = viji.toggle(true, { label: 'Mouse Dot', category: 'interaction' });\r\n```\r\n\r\n- `baseColor` ([`viji.color()`](../color/)) is always visible.\r\n- `pulseAmount` ([`viji.slider()`](../slider/)) only appears when audio is connected.\r\n- `videoOpacity` ([`viji.slider()`](../slider/)) only appears when a video/camera source is connected.\r\n- `showMouse` ([`viji.toggle()`](../toggle/)) only appears when interaction is enabled.\r\n\r\nIf you omit `category`, it defaults to `'general'` (always visible).\r\n\r\n## How It Works\r\n\r\n1. The artist sets `category` on each parameter during scene initialization.\r\n2. When the host application requests parameters, Viji filters them based on the current `CoreCapabilities`:\r\n - `hasAudio`: is an audio stream connected?\r\n - `hasVideo`: is a video/camera stream connected?\r\n - `hasInteraction`: is user interaction enabled?\r\n - `hasGeneral`: always `true`.\r\n3. Only parameters matching active capabilities are sent to the UI.\r\n\r\n> [!NOTE]\r\n> Categories filter at both the **group level** and the **individual parameter level**. If a group's category doesn't match, the entire group is hidden. If individual parameters within a visible group have non-matching categories, those parameters are hidden while the group remains visible.\r\n\r\n## Live Example\r\n\r\nParameters in all four categories: `general` (always visible), `audio` (needs audio), `video` (needs camera), and `interaction` (needs mouse). Each control reveals itself as you connect the corresponding capability:"
5228
5253
  },
5229
5254
  {
5230
5255
  "type": "live-example",
5231
5256
  "title": "P5 Parameter Categories",
5232
- "sceneCode": "// @renderer p5\r\n\r\nconst baseColor = viji.color('#4488ff', { label: 'Base Color', category: 'general' });\r\nconst pulseAmount = viji.slider(0.3, { min: 0, max: 1, step: 0.01, label: 'Audio Pulse', category: 'audio' });\r\nconst showMouse = viji.toggle(true, { label: 'Mouse Dot', category: 'interaction' });\r\n\r\nlet angle = 0;\r\n\r\nfunction render(viji, p5) {\r\n p5.background(10, 10, 30, 40);\r\n\r\n angle += viji.deltaTime;\r\n\r\n const r = parseInt(baseColor.value.slice(1, 3), 16);\r\n const g = parseInt(baseColor.value.slice(3, 5), 16);\r\n const b = parseInt(baseColor.value.slice(5, 7), 16);\r\n\r\n let pulse = 0;\r\n if (viji.audio.isConnected) {\r\n pulse = viji.audio.volume.current * pulseAmount.value;\r\n }\r\n\r\n const baseR = Math.min(viji.width, viji.height) * (0.1 + pulse * 0.15);\r\n const cx = viji.width / 2 + p5.cos(angle) * viji.width * 0.2;\r\n const cy = viji.height / 2 + p5.sin(angle * 0.7) * viji.height * 0.2;\r\n\r\n p5.noStroke();\r\n p5.fill(r, g, b);\r\n p5.circle(cx, cy, baseR * 2);\r\n\r\n if (showMouse.value && viji.mouse.isInCanvas) {\r\n p5.fill(255, 255, 255, 200);\r\n p5.circle(viji.mouse.x, viji.mouse.y, Math.min(viji.width, viji.height) * 0.04);\r\n }\r\n}\r\n",
5257
+ "sceneCode": "// @renderer p5\r\n\r\nconst baseColor = viji.color('#4488ff', { label: 'Base Color', category: 'general' });\r\nconst pulseAmount = viji.slider(0.3, { min: 0, max: 1, step: 0.01, label: 'Audio Pulse', category: 'audio' });\r\nconst videoOpacity = viji.slider(0.6, { min: 0, max: 1, step: 0.01, label: 'Video Opacity', category: 'video' });\r\nconst showMouse = viji.toggle(true, { label: 'Mouse Dot', category: 'interaction' });\r\n\r\nlet angle = 0;\r\n\r\nfunction render(viji, p5) {\r\n if (viji.video.isConnected && viji.video.currentFrame && videoOpacity.value > 0) {\r\n p5.background(0);\r\n p5.tint(255, videoOpacity.value * 255);\r\n p5.image(viji.video.currentFrame, 0, 0, viji.width, viji.height);\r\n p5.noTint();\r\n } else {\r\n p5.background(10, 10, 30, 40);\r\n }\r\n\r\n angle += viji.deltaTime;\r\n\r\n const r = parseInt(baseColor.value.slice(1, 3), 16);\r\n const g = parseInt(baseColor.value.slice(3, 5), 16);\r\n const b = parseInt(baseColor.value.slice(5, 7), 16);\r\n\r\n let pulse = 0;\r\n if (viji.audio.isConnected) {\r\n pulse = viji.audio.volume.current * pulseAmount.value;\r\n }\r\n\r\n const baseR = Math.min(viji.width, viji.height) * (0.1 + pulse * 0.15);\r\n const cx = viji.width / 2 + p5.cos(angle) * viji.width * 0.2;\r\n const cy = viji.height / 2 + p5.sin(angle * 0.7) * viji.height * 0.2;\r\n\r\n p5.noStroke();\r\n p5.fill(r, g, b);\r\n p5.circle(cx, cy, baseR * 2);\r\n\r\n if (showMouse.value && viji.mouse.isInCanvas) {\r\n p5.fill(255, 255, 255, 200);\r\n p5.circle(viji.mouse.x, viji.mouse.y, Math.min(viji.width, viji.height) * 0.04);\r\n }\r\n}\r\n",
5233
5258
  "sceneFile": "categories-demo.scene.js",
5234
5259
  "capabilities": {
5235
5260
  "audio": true,
5261
+ "video": true,
5236
5262
  "interaction": true
5237
5263
  }
5238
5264
  },
@@ -5277,7 +5303,10 @@ export const docsApi = {
5277
5303
  "type": "live-example",
5278
5304
  "title": "Audio-Reactive Circle",
5279
5305
  "sceneCode": "function setup(viji, p5) {\r\n p5.colorMode(p5.HSB, 360, 100, 100, 1);\r\n}\r\n\r\nfunction render(viji, p5) {\r\n p5.background(0, 0, 10);\r\n\r\n if (!viji.audio.isConnected) {\r\n p5.fill(100);\r\n p5.textAlign(p5.CENTER, p5.CENTER);\r\n p5.textSize(viji.width * 0.04);\r\n p5.text('Waiting for audio...', viji.width / 2, viji.height / 2);\r\n return;\r\n }\r\n\r\n const vol = viji.audio.volume.smoothed;\r\n const r = Math.min(viji.width, viji.height) * (0.1 + vol * 0.3);\r\n\r\n p5.noStroke();\r\n p5.fill(200 + vol * 160, 80, 60);\r\n p5.circle(viji.width / 2, viji.height / 2, r * 2);\r\n}\r\n",
5280
- "sceneFile": "audio-overview.scene.js"
5306
+ "sceneFile": "audio-overview.scene.js",
5307
+ "capabilities": {
5308
+ "audio": true
5309
+ }
5281
5310
  },
5282
5311
  {
5283
5312
  "type": "text",
@@ -5325,7 +5354,10 @@ export const docsApi = {
5325
5354
  "type": "live-example",
5326
5355
  "title": "Connection State",
5327
5356
  "sceneCode": "function setup(viji, p5) {\r\n p5.textAlign(p5.CENTER, p5.CENTER);\r\n}\r\n\r\nfunction render(viji, p5) {\r\n p5.background(15);\r\n\r\n const fontSize = Math.min(viji.width, viji.height) * 0.035;\r\n p5.textSize(fontSize);\r\n\r\n if (!viji.audio.isConnected) {\r\n const pulse = 0.4 + Math.sin(viji.time * 2) * 0.15;\r\n p5.fill(255, 255, 255, pulse * 255);\r\n p5.text('Waiting for audio stream...', viji.width / 2, viji.height / 2 - fontSize);\r\n p5.fill(80);\r\n p5.text('Connect a microphone or audio source', viji.width / 2, viji.height / 2 + fontSize);\r\n return;\r\n }\r\n\r\n const vol = viji.audio.volume.smoothed;\r\n const barW = viji.width * 0.6;\r\n const barH = Math.min(viji.width, viji.height) * 0.06;\r\n const barX = (viji.width - barW) / 2;\r\n const barY = viji.height / 2 - barH / 2;\r\n\r\n p5.noStroke();\r\n p5.fill(30);\r\n p5.rect(barX, barY, barW, barH);\r\n p5.fill(76, 175, 80);\r\n p5.rect(barX, barY, barW * vol, barH);\r\n\r\n p5.fill(170);\r\n p5.text('Audio connected: volume: ' + vol.toFixed(2), viji.width / 2, barY - fontSize);\r\n}\r\n",
5328
- "sceneFile": "connection-demo.scene.js"
5357
+ "sceneFile": "connection-demo.scene.js",
5358
+ "capabilities": {
5359
+ "audio": true
5360
+ }
5329
5361
  },
5330
5362
  {
5331
5363
  "type": "text",
@@ -5368,7 +5400,10 @@ export const docsApi = {
5368
5400
  "type": "live-example",
5369
5401
  "title": "Volume Meters",
5370
5402
  "sceneCode": "function setup(viji, p5) {\r\n p5.textAlign(p5.LEFT, p5.TOP);\r\n}\r\n\r\nfunction render(viji, p5) {\r\n p5.background(15);\r\n\r\n if (!viji.audio.isConnected) {\r\n p5.fill(100);\r\n p5.textAlign(p5.CENTER, p5.CENTER);\r\n p5.textSize(viji.width * 0.04);\r\n p5.text('Waiting for audio...', viji.width / 2, viji.height / 2);\r\n return;\r\n }\r\n\r\n const vol = viji.audio.volume;\r\n const barH = viji.height * 0.06;\r\n const gap = barH * 0.5;\r\n const barW = viji.width * 0.7;\r\n const x = (viji.width - barW) / 2;\r\n let y = viji.height * 0.3;\r\n\r\n p5.noStroke();\r\n p5.textSize(barH * 0.7);\r\n p5.textAlign(p5.LEFT, p5.TOP);\r\n\r\n p5.fill(76, 175, 80);\r\n p5.rect(x, y, barW * vol.current, barH);\r\n p5.fill(170);\r\n p5.text('current: ' + vol.current.toFixed(3), x, y - barH);\r\n y += barH + gap;\r\n\r\n p5.fill(255, 152, 0);\r\n p5.rect(x, y, barW * vol.peak, barH);\r\n p5.fill(170);\r\n p5.text('peak: ' + vol.peak.toFixed(3), x, y - barH);\r\n y += barH + gap;\r\n\r\n p5.fill(33, 150, 243);\r\n p5.rect(x, y, barW * vol.smoothed, barH);\r\n p5.fill(170);\r\n p5.text('smoothed: ' + vol.smoothed.toFixed(3), x, y - barH);\r\n}\r\n",
5371
- "sceneFile": "volume-demo.scene.js"
5403
+ "sceneFile": "volume-demo.scene.js",
5404
+ "capabilities": {
5405
+ "audio": true
5406
+ }
5372
5407
  },
5373
5408
  {
5374
5409
  "type": "text",
@@ -5421,7 +5456,10 @@ export const docsApi = {
5421
5456
  "type": "live-example",
5422
5457
  "title": "Frequency Band Bars",
5423
5458
  "sceneCode": "function render(viji, p5) {\r\n p5.background(15);\r\n\r\n if (!viji.audio.isConnected) {\r\n p5.fill(100);\r\n p5.textAlign(p5.CENTER, p5.CENTER);\r\n p5.textSize(viji.width * 0.04);\r\n p5.text('Waiting for audio...', viji.width / 2, viji.height / 2);\r\n return;\r\n }\r\n\r\n const bands = viji.audio.bands;\r\n const names = ['low', 'lowMid', 'mid', 'highMid', 'high'];\r\n const colors = [\r\n p5.color(231, 76, 60),\r\n p5.color(230, 126, 34),\r\n p5.color(241, 196, 15),\r\n p5.color(46, 204, 113),\r\n p5.color(52, 152, 219)\r\n ];\r\n const barW = viji.width / names.length;\r\n\r\n p5.noStroke();\r\n\r\n for (let i = 0; i < names.length; i++) {\r\n const instant = bands[names[i]];\r\n const smoothed = bands[names[i] + 'Smoothed'];\r\n const x = i * barW;\r\n\r\n const c = colors[i];\r\n p5.fill(p5.red(c), p5.green(c), p5.blue(c), 60);\r\n p5.rect(x + 2, viji.height - smoothed * viji.height, barW - 4, smoothed * viji.height);\r\n\r\n p5.fill(c);\r\n p5.rect(x + 2, viji.height - instant * viji.height, barW - 4, instant * viji.height);\r\n }\r\n\r\n p5.fill(170);\r\n p5.textAlign(p5.CENTER, p5.BOTTOM);\r\n p5.textSize(Math.min(viji.width, viji.height) * 0.03);\r\n for (let i = 0; i < names.length; i++) {\r\n p5.text(names[i], i * barW + barW / 2, viji.height - 8);\r\n }\r\n}\r\n",
5424
- "sceneFile": "bands-demo.scene.js"
5459
+ "sceneFile": "bands-demo.scene.js",
5460
+ "capabilities": {
5461
+ "audio": true
5462
+ }
5425
5463
  },
5426
5464
  {
5427
5465
  "type": "text",
@@ -5489,7 +5527,10 @@ export const docsApi = {
5489
5527
  "type": "live-example",
5490
5528
  "title": "Beat Pulses",
5491
5529
  "sceneCode": "let hue = 0;\r\n\r\nfunction setup(viji, p5) {\r\n p5.colorMode(p5.HSB, 360, 100, 100, 100);\r\n}\r\n\r\nfunction render(viji, p5) {\r\n if (!viji.audio.isConnected) {\r\n p5.background(0, 0, 10);\r\n p5.fill(0, 0, 50);\r\n p5.textAlign(p5.CENTER, p5.CENTER);\r\n p5.textSize(viji.width * 0.04);\r\n p5.text('Waiting for audio...', viji.width / 2, viji.height / 2);\r\n return;\r\n }\r\n\r\n const beat = viji.audio.beat;\r\n\r\n if (beat.triggers.kick) {\r\n hue = (hue + 30) % 360;\r\n }\r\n\r\n p5.fill(hue, 70, 50, 8);\r\n p5.rect(0, 0, viji.width, viji.height);\r\n\r\n const cx = viji.width / 2;\r\n const cy = viji.height / 2;\r\n const baseR = Math.min(viji.width, viji.height) * 0.08;\r\n\r\n p5.noStroke();\r\n\r\n p5.fill(0, 80, 70, (0.3 + beat.kick * 0.7) * 100);\r\n p5.circle(cx - viji.width * 0.2, cy, (baseR + beat.kick * baseR * 2) * 2);\r\n\r\n p5.fill(50, 80, 70, (0.3 + beat.snare * 0.7) * 100);\r\n p5.circle(cx, cy, (baseR + beat.snare * baseR * 1.5) * 2);\r\n\r\n p5.fill(200, 80, 70, (0.3 + beat.hat * 0.7) * 100);\r\n p5.circle(cx + viji.width * 0.2, cy, (baseR + beat.hat * baseR) * 2);\r\n\r\n if (beat.triggers.any) {\r\n const x = p5.random(viji.width);\r\n const y = p5.random(viji.height);\r\n const r = Math.min(viji.width, viji.height) * p5.random(0.02, 0.06);\r\n p5.fill(hue, 80, 60);\r\n p5.circle(x, y, r * 2);\r\n }\r\n}\r\n",
5492
- "sceneFile": "beat-demo.scene.js"
5530
+ "sceneFile": "beat-demo.scene.js",
5531
+ "capabilities": {
5532
+ "audio": true
5533
+ }
5493
5534
  },
5494
5535
  {
5495
5536
  "type": "text",
@@ -5532,7 +5573,10 @@ export const docsApi = {
5532
5573
  "type": "live-example",
5533
5574
  "title": "Spectral Features",
5534
5575
  "sceneCode": "function setup(viji, p5) {\r\n p5.colorMode(p5.HSB, 360, 100, 100, 1);\r\n}\r\n\r\nfunction render(viji, p5) {\r\n p5.background(0, 0, 8);\r\n\r\n if (!viji.audio.isConnected) {\r\n p5.fill(0, 0, 50);\r\n p5.textAlign(p5.CENTER, p5.CENTER);\r\n p5.textSize(viji.width * 0.04);\r\n p5.text('Waiting for audio...', viji.width / 2, viji.height / 2);\r\n return;\r\n }\r\n\r\n const brightness = viji.audio.spectral.brightness;\r\n const flatness = viji.audio.spectral.flatness;\r\n\r\n const hue = 20 + brightness * 200;\r\n const sat = 30 + (1 - flatness) * 60;\r\n\r\n const r = Math.min(viji.width, viji.height) * 0.25;\r\n p5.noStroke();\r\n p5.fill(hue, sat, 55);\r\n p5.circle(viji.width / 2, viji.height / 2, r * 2);\r\n\r\n p5.fill(0, 0, 70);\r\n p5.textAlign(p5.CENTER, p5.CENTER);\r\n p5.textSize(Math.min(viji.width, viji.height) * 0.03);\r\n p5.text(\r\n `brightness: ${brightness.toFixed(2)} flatness: ${flatness.toFixed(2)}`,\r\n viji.width / 2,\r\n viji.height * 0.88\r\n );\r\n}\r\n",
5535
- "sceneFile": "spectral-demo.scene.js"
5576
+ "sceneFile": "spectral-demo.scene.js",
5577
+ "capabilities": {
5578
+ "audio": true
5579
+ }
5536
5580
  },
5537
5581
  {
5538
5582
  "type": "text",
@@ -5580,7 +5624,10 @@ export const docsApi = {
5580
5624
  "type": "live-example",
5581
5625
  "title": "Spectrum Visualizer",
5582
5626
  "sceneCode": "function render(viji, p5) {\r\n p5.background(15);\r\n\r\n if (!viji.audio.isConnected) {\r\n p5.fill(100);\r\n p5.textAlign(p5.CENTER, p5.CENTER);\r\n p5.textSize(viji.width * 0.04);\r\n p5.text('Waiting for audio...', viji.width / 2, viji.height / 2);\r\n return;\r\n }\r\n\r\n const fft = viji.audio.getFrequencyData();\r\n if (fft.length === 0) return;\r\n\r\n p5.colorMode(p5.HSB, 360, 100, 100);\r\n p5.noStroke();\r\n\r\n const bars = 64;\r\n const binCount = fft.length;\r\n const logMax = Math.log(binCount);\r\n const barW = viji.width / bars;\r\n\r\n for (let i = 0; i < bars; i++) {\r\n const logStart = Math.exp((i / bars) * logMax);\r\n const logEnd = Math.exp(((i + 1) / bars) * logMax);\r\n const startBin = Math.floor(logStart);\r\n const endBin = Math.min(Math.floor(logEnd), binCount - 1);\r\n\r\n let sum = 0;\r\n let count = 0;\r\n for (let b = startBin; b <= endBin; b++) {\r\n sum += fft[b];\r\n count++;\r\n }\r\n const value = count > 0 ? (sum / count) / 255 : 0;\r\n\r\n const barH = value * viji.height * 0.9;\r\n p5.fill((i / bars) * 280, 70, 35 + value * 35);\r\n p5.rect(i * barW + 1, viji.height - barH, barW - 2, barH);\r\n }\r\n}\r\n",
5583
- "sceneFile": "frequency-data-demo.scene.js"
5627
+ "sceneFile": "frequency-data-demo.scene.js",
5628
+ "capabilities": {
5629
+ "audio": true
5630
+ }
5584
5631
  },
5585
5632
  {
5586
5633
  "type": "text",
@@ -5628,7 +5675,10 @@ export const docsApi = {
5628
5675
  "type": "live-example",
5629
5676
  "title": "Oscilloscope",
5630
5677
  "sceneCode": "function render(viji, p5) {\r\n p5.background(15);\r\n\r\n if (!viji.audio.isConnected) {\r\n p5.fill(100);\r\n p5.textAlign(p5.CENTER, p5.CENTER);\r\n p5.textSize(viji.width * 0.04);\r\n p5.text('Waiting for audio...', viji.width / 2, viji.height / 2);\r\n return;\r\n }\r\n\r\n const waveform = viji.audio.getWaveform();\r\n if (waveform.length === 0) return;\r\n\r\n // Oscilloscope line\r\n p5.noFill();\r\n p5.stroke(76, 175, 80);\r\n p5.strokeWeight(2);\r\n p5.beginShape();\r\n\r\n for (let i = 0; i < waveform.length; i++) {\r\n const x = (i / waveform.length) * viji.width;\r\n const y = viji.height / 2 + waveform[i] * viji.height * 0.4;\r\n p5.vertex(x, y);\r\n }\r\n\r\n p5.endShape();\r\n\r\n // Center line\r\n p5.stroke(50);\r\n p5.strokeWeight(1);\r\n p5.line(0, viji.height / 2, viji.width, viji.height / 2);\r\n}\r\n",
5631
- "sceneFile": "waveform-demo.scene.js"
5678
+ "sceneFile": "waveform-demo.scene.js",
5679
+ "capabilities": {
5680
+ "audio": true
5681
+ }
5632
5682
  },
5633
5683
  {
5634
5684
  "type": "text",
@@ -7928,15 +7978,16 @@ export const docsApi = {
7928
7978
  "content": [
7929
7979
  {
7930
7980
  "type": "text",
7931
- "markdown": "# Shader Parameter Categories\r\n\r\nCategories let you tag parameters so they're only visible when the corresponding capability is active. An \"Audio Pulse\" [`@viji-slider`](../slider/) is useless if no audio source is connected: with `category:audio`, it automatically appears when audio is available and hides when it's not. In shaders, set the `category:` key in any `@viji-*` directive.\r\n\r\n## The Four Categories\r\n\r\n| Category | Visible When | Use For |\r\n|---|---|---|\r\n| `general` | Always | Colors, sizes, speeds, shapes: anything that works without external input |\r\n| `audio` | Audio source is connected | Volume scaling, beat reactivity, frequency controls |\r\n| `video` | Video/camera source is connected | Video opacity, CV sensitivity, segmentation controls |\r\n| `interaction` | User interaction is enabled | Mouse effects, keyboard bindings, touch sensitivity |\r\n\r\n## Usage\r\n\r\n```glsl\r\n// @renderer shader\r\n// @viji-color:tint label:\"Color\" default:#4488ff category:general\r\n// @viji-slider:audioPulse label:\"Audio Pulse\" default:0.3 min:0.0 max:1.0 category:audio\r\n// @viji-slider:mouseSize label:\"Mouse Glow\" default:0.15 min:0.0 max:0.5 category:interaction\r\n```\r\n\r\n- `tint` ([`@viji-color`](../color/)) is always visible.\r\n- `audioPulse` ([`@viji-slider`](../slider/)) only appears when audio is connected.\r\n- `mouseSize` ([`@viji-slider`](../slider/)) only appears when interaction is enabled.\r\n\r\nIf you omit `category`, it defaults to `general` (always visible).\r\n\r\n## How It Works\r\n\r\n1. The artist sets `category:` on each directive during scene declaration.\r\n2. The shader parameter parser extracts `category` along with other config keys.\r\n3. When the host application requests parameters, Viji filters them based on the current `CoreCapabilities`:\r\n - `hasAudio`: is an audio stream connected?\r\n - `hasVideo`: is a video/camera stream connected?\r\n - `hasInteraction`: is user interaction enabled?\r\n - `hasGeneral`: always `true`.\r\n4. Only parameters matching active capabilities are sent to the UI.\r\n\r\n> [!NOTE]\r\n> Categories filter at both the **group level** and the **individual parameter level**. If a group's category doesn't match, the entire group is hidden. If individual parameters within a visible group have non-matching categories, those parameters are hidden while the group remains visible.\r\n\r\n> [!NOTE]\r\n> In shader directives, category values are **unquoted strings**: `category:audio`, not `category:\"audio\"`. Both forms work, but the unquoted form is conventional.\r\n\r\n## Live Example\r\n\r\nParameters in three categories: `general` (always visible), `audio` (needs audio), and `interaction` (needs mouse):"
7981
+ "markdown": "# Shader Parameter Categories\r\n\r\nCategories let you tag parameters so they're only visible when the corresponding capability is active. An \"Audio Pulse\" [`@viji-slider`](../slider/) is useless if no audio source is connected: with `category:audio`, it automatically appears when audio is available and hides when it's not. In shaders, set the `category:` key in any `@viji-*` directive.\r\n\r\n## The Four Categories\r\n\r\n| Category | Visible When | Use For |\r\n|---|---|---|\r\n| `general` | Always | Colors, sizes, speeds, shapes: anything that works without external input |\r\n| `audio` | Audio source is connected | Volume scaling, beat reactivity, frequency controls |\r\n| `video` | Video/camera source is connected | Video opacity, CV sensitivity, segmentation controls |\r\n| `interaction` | User interaction is enabled | Mouse effects, keyboard bindings, touch sensitivity |\r\n\r\n## Usage\r\n\r\n```glsl\r\n// @renderer shader\r\n// @viji-color:tint label:\"Color\" default:#4488ff category:general\r\n// @viji-slider:audioPulse label:\"Audio Pulse\" default:0.3 min:0.0 max:1.0 category:audio\r\n// @viji-slider:videoMix label:\"Video Mix\" default:0.0 min:0.0 max:1.0 category:video\r\n// @viji-slider:mouseSize label:\"Mouse Glow\" default:0.15 min:0.0 max:0.5 category:interaction\r\n```\r\n\r\n- `tint` ([`@viji-color`](../color/)) is always visible.\r\n- `audioPulse` ([`@viji-slider`](../slider/)) only appears when audio is connected.\r\n- `videoMix` ([`@viji-slider`](../slider/)) only appears when a video/camera source is connected.\r\n- `mouseSize` ([`@viji-slider`](../slider/)) only appears when interaction is enabled.\r\n\r\nIf you omit `category`, it defaults to `general` (always visible).\r\n\r\n## How It Works\r\n\r\n1. The artist sets `category:` on each directive during scene declaration.\r\n2. The shader parameter parser extracts `category` along with other config keys.\r\n3. When the host application requests parameters, Viji filters them based on the current `CoreCapabilities`:\r\n - `hasAudio`: is an audio stream connected?\r\n - `hasVideo`: is a video/camera stream connected?\r\n - `hasInteraction`: is user interaction enabled?\r\n - `hasGeneral`: always `true`.\r\n4. Only parameters matching active capabilities are sent to the UI.\r\n\r\n> [!NOTE]\r\n> Categories filter at both the **group level** and the **individual parameter level**. If a group's category doesn't match, the entire group is hidden. If individual parameters within a visible group have non-matching categories, those parameters are hidden while the group remains visible.\r\n\r\n> [!NOTE]\r\n> In shader directives, category values are **unquoted strings**: `category:audio`, not `category:\"audio\"`. Both forms work, but the unquoted form is conventional.\r\n\r\n## Live Example\r\n\r\nParameters in all four categories: `general` (always visible), `audio` (needs audio), `video` (needs camera), and `interaction` (needs mouse). Each control reveals itself as you connect the corresponding capability:"
7932
7982
  },
7933
7983
  {
7934
7984
  "type": "live-example",
7935
7985
  "title": "Shader Parameter Categories",
7936
- "sceneCode": "// @renderer shader\r\n// @viji-color:tint label:\"Color\" default:#4488ff category:general\r\n// @viji-slider:audioPulse label:\"Audio Pulse\" default:0.3 min:0.0 max:1.0 category:audio\r\n// @viji-slider:mouseSize label:\"Mouse Glow\" default:0.15 min:0.0 max:0.5 category:interaction\r\n// @viji-accumulator:phase rate:1.0\r\n\r\nvoid main() {\r\n vec2 uv = (2.0 * gl_FragCoord.xy - u_resolution) / u_resolution.y;\r\n float d = length(uv);\r\n\r\n float pulse = u_audioVolume * audioPulse;\r\n float wave = sin(d * 15.0 - phase * 3.0) * 0.5 + 0.5;\r\n vec3 col = tint * wave * (1.0 + pulse);\r\n\r\n vec2 mouseUV = (2.0 * u_mouse - u_resolution) / u_resolution.y;\r\n float mouseDist = length(uv - mouseUV);\r\n float glow = mouseSize / (mouseDist + 0.05);\r\n col += vec3(glow * 0.3);\r\n\r\n col *= smoothstep(1.5, 0.3, d);\r\n gl_FragColor = vec4(col, 1.0);\r\n}\r\n",
7986
+ "sceneCode": "// @renderer shader\r\n// @viji-color:tint label:\"Color\" default:#4488ff category:general\r\n// @viji-slider:audioPulse label:\"Audio Pulse\" default:0.3 min:0.0 max:1.0 category:audio\r\n// @viji-slider:videoMix label:\"Video Mix\" default:0.0 min:0.0 max:1.0 step:0.01 category:video\r\n// @viji-slider:mouseSize label:\"Mouse Glow\" default:0.15 min:0.0 max:0.5 category:interaction\r\n// @viji-accumulator:phase rate:1.0\r\n\r\nvoid main() {\r\n vec2 uv = (2.0 * gl_FragCoord.xy - u_resolution) / u_resolution.y;\r\n vec2 vuv = gl_FragCoord.xy / u_resolution;\r\n float d = length(uv);\r\n\r\n float pulse = u_audioVolume * audioPulse;\r\n float wave = sin(d * 15.0 - phase * 3.0) * 0.5 + 0.5;\r\n vec3 col = tint * wave * (1.0 + pulse);\r\n\r\n vec2 mouseUV = (2.0 * u_mouse - u_resolution) / u_resolution.y;\r\n float mouseDist = length(uv - mouseUV);\r\n float glow = mouseSize / (mouseDist + 0.05);\r\n col += vec3(glow * 0.3);\r\n\r\n col *= smoothstep(1.5, 0.3, d);\r\n\r\n vec3 video = texture2D(u_video, vuv).rgb;\r\n col = mix(col, video, videoMix);\r\n\r\n gl_FragColor = vec4(col, 1.0);\r\n}\r\n",
7937
7987
  "sceneFile": "categories-demo.scene.glsl",
7938
7988
  "capabilities": {
7939
7989
  "audio": true,
7990
+ "video": true,
7940
7991
  "interaction": true
7941
7992
  }
7942
7993
  },
@@ -7981,7 +8032,10 @@ export const docsApi = {
7981
8032
  "type": "live-example",
7982
8033
  "title": "Audio-Reactive Shader",
7983
8034
  "sceneCode": "// @renderer shader\r\n\r\nvoid main() {\r\n vec2 uv = gl_FragCoord.xy / u_resolution;\r\n\r\n float pulse = u_audioVolumeSmoothed;\r\n\r\n float r = u_audioLowSmoothed;\r\n float g = u_audioMidSmoothed;\r\n float b = u_audioHighSmoothed;\r\n\r\n float flash = u_audioKickTrigger ? 0.3 : 0.0;\r\n\r\n vec3 col = vec3(r, g, b) * (0.3 + pulse * 0.7) + vec3(flash);\r\n gl_FragColor = vec4(col, 1.0);\r\n}\r\n",
7984
- "sceneFile": "audio-overview.scene.js"
8035
+ "sceneFile": "audio-overview.scene.js",
8036
+ "capabilities": {
8037
+ "audio": true
8038
+ }
7985
8039
  },
7986
8040
  {
7987
8041
  "type": "text",
@@ -8024,7 +8078,10 @@ export const docsApi = {
8024
8078
  "type": "live-example",
8025
8079
  "title": "Volume Pulse",
8026
8080
  "sceneCode": "// @renderer shader\r\n\r\nvoid main() {\r\n vec2 uv = gl_FragCoord.xy / u_resolution;\r\n\r\n vec2 center = vec2(0.5);\r\n float radius = 0.1 + u_audioVolumeSmoothed * 0.3;\r\n float d = length(uv - center);\r\n float circle = smoothstep(radius, radius - 0.01, d);\r\n\r\n vec3 col = vec3(0.2, 0.6, 1.0) * circle * (0.4 + u_audioVolume * 0.6);\r\n\r\n gl_FragColor = vec4(col, 1.0);\r\n}\r\n",
8027
- "sceneFile": "volume-demo.scene.js"
8081
+ "sceneFile": "volume-demo.scene.js",
8082
+ "capabilities": {
8083
+ "audio": true
8084
+ }
8028
8085
  },
8029
8086
  {
8030
8087
  "type": "text",
@@ -8077,7 +8134,10 @@ export const docsApi = {
8077
8134
  "type": "live-example",
8078
8135
  "title": "Band Bars",
8079
8136
  "sceneCode": "// @renderer shader\r\n\r\nvoid main() {\r\n vec2 uv = gl_FragCoord.xy / u_resolution;\r\n\r\n float col_r = 0.0, col_g = 0.0, col_b = 0.0;\r\n\r\n if (uv.x < 0.2) {\r\n col_r = step(uv.y, u_audioLowSmoothed) * 0.9;\r\n } else if (uv.x < 0.4) {\r\n col_r = step(uv.y, u_audioLowMidSmoothed) * 0.8;\r\n col_g = col_r * 0.4;\r\n } else if (uv.x < 0.6) {\r\n col_g = step(uv.y, u_audioMidSmoothed) * 0.9;\r\n col_r = col_g * 0.6;\r\n } else if (uv.x < 0.8) {\r\n col_g = step(uv.y, u_audioHighMidSmoothed) * 0.7;\r\n col_b = col_g * 0.5;\r\n } else {\r\n col_b = step(uv.y, u_audioHighSmoothed) * 0.9;\r\n }\r\n\r\n gl_FragColor = vec4(col_r, col_g, col_b, 1.0);\r\n}\r\n",
8080
- "sceneFile": "bands-demo.scene.js"
8137
+ "sceneFile": "bands-demo.scene.js",
8138
+ "capabilities": {
8139
+ "audio": true
8140
+ }
8081
8141
  },
8082
8142
  {
8083
8143
  "type": "text",
@@ -8140,7 +8200,10 @@ export const docsApi = {
8140
8200
  "type": "live-example",
8141
8201
  "title": "Beat Pulses",
8142
8202
  "sceneCode": "// @renderer shader\r\n\r\nvoid main() {\r\n vec2 uv = gl_FragCoord.xy / u_resolution;\r\n\r\n // Three circles for kick, snare, hat\r\n vec2 c1 = vec2(0.25, 0.5);\r\n vec2 c2 = vec2(0.5, 0.5);\r\n vec2 c3 = vec2(0.75, 0.5);\r\n\r\n float r1 = 0.05 + u_audioKick * 0.15;\r\n float r2 = 0.05 + u_audioSnare * 0.12;\r\n float r3 = 0.05 + u_audioHat * 0.08;\r\n\r\n float d1 = smoothstep(r1, r1 - 0.01, length(uv - c1));\r\n float d2 = smoothstep(r2, r2 - 0.01, length(uv - c2));\r\n float d3 = smoothstep(r3, r3 - 0.01, length(uv - c3));\r\n\r\n vec3 col = vec3(0.9, 0.3, 0.2) * d1\r\n + vec3(0.9, 0.8, 0.1) * d2\r\n + vec3(0.2, 0.6, 0.9) * d3;\r\n\r\n // Flash on kick trigger\r\n float flash = u_audioKickTrigger ? 0.15 : 0.0;\r\n col += vec3(flash);\r\n\r\n gl_FragColor = vec4(col, 1.0);\r\n}\r\n",
8143
- "sceneFile": "beat-demo.scene.js"
8203
+ "sceneFile": "beat-demo.scene.js",
8204
+ "capabilities": {
8205
+ "audio": true
8206
+ }
8144
8207
  },
8145
8208
  {
8146
8209
  "type": "text",
@@ -8183,7 +8246,10 @@ export const docsApi = {
8183
8246
  "type": "live-example",
8184
8247
  "title": "Spectral Features",
8185
8248
  "sceneCode": "// @renderer shader\r\n\r\nvoid main() {\r\n vec2 uv = gl_FragCoord.xy / u_resolution;\r\n\r\n float hue = 0.05 + u_audioBrightness * 0.55;\r\n float sat = 0.3 + (1.0 - u_audioFlatness) * 0.6;\r\n\r\n vec3 col = vec3(abs(hue * 6.0 - 3.0) - 1.0,\r\n 2.0 - abs(hue * 6.0 - 2.0),\r\n 2.0 - abs(hue * 6.0 - 4.0));\r\n col = clamp(col, 0.0, 1.0);\r\n col = mix(vec3(1.0), col, sat);\r\n col *= 0.55;\r\n\r\n float d = length(uv - vec2(0.5));\r\n float circle = smoothstep(0.25, 0.24, d);\r\n col *= circle;\r\n\r\n gl_FragColor = vec4(col, 1.0);\r\n}\r\n",
8186
- "sceneFile": "spectral-demo.scene.js"
8249
+ "sceneFile": "spectral-demo.scene.js",
8250
+ "capabilities": {
8251
+ "audio": true
8252
+ }
8187
8253
  },
8188
8254
  {
8189
8255
  "type": "text",
@@ -8241,7 +8307,10 @@ export const docsApi = {
8241
8307
  "type": "live-example",
8242
8308
  "title": "FFT Spectrum",
8243
8309
  "sceneCode": "// @renderer shader\r\n\r\nvoid main() {\r\n vec2 uv = gl_FragCoord.xy / u_resolution;\r\n\r\n // Logarithmic frequency mapping for musical distribution\r\n float logFreq = pow(uv.x, 3.0);\r\n float mag = texture2D(u_audioFFT, vec2(logFreq, 0.5)).r;\r\n\r\n // Bar from bottom\r\n float bar = smoothstep(uv.y - 0.01, uv.y, mag);\r\n\r\n // Color gradient by frequency position\r\n vec3 col = mix(vec3(0.8, 0.2, 0.1), vec3(0.1, 0.5, 1.0), uv.x) * bar;\r\n\r\n gl_FragColor = vec4(col, 1.0);\r\n}\r\n",
8244
- "sceneFile": "fft-demo.scene.js"
8310
+ "sceneFile": "fft-demo.scene.js",
8311
+ "capabilities": {
8312
+ "audio": true
8313
+ }
8245
8314
  },
8246
8315
  {
8247
8316
  "type": "text",
@@ -8294,7 +8363,10 @@ export const docsApi = {
8294
8363
  "type": "live-example",
8295
8364
  "title": "Waveform Oscilloscope",
8296
8365
  "sceneCode": "// @renderer shader\r\n\r\nvoid main() {\r\n vec2 uv = gl_FragCoord.xy / u_resolution;\r\n\r\n // Sample waveform at x position\r\n float raw = texture2D(u_audioWaveform, vec2(uv.x, 0.5)).r;\r\n float sample = raw * 2.0 - 1.0;\r\n\r\n // Map to screen Y\r\n float waveY = 0.5 + sample * 0.4;\r\n\r\n // Draw as a thin line\r\n float line = smoothstep(0.008, 0.0, abs(uv.y - waveY));\r\n\r\n // Center line\r\n float centerLine = smoothstep(0.002, 0.0, abs(uv.y - 0.5)) * 0.15;\r\n\r\n vec3 col = vec3(0.3, 0.8, 0.4) * line + vec3(centerLine);\r\n\r\n gl_FragColor = vec4(col, 1.0);\r\n}\r\n",
8297
- "sceneFile": "waveform-demo.scene.js"
8366
+ "sceneFile": "waveform-demo.scene.js",
8367
+ "capabilities": {
8368
+ "audio": true
8369
+ }
8298
8370
  },
8299
8371
  {
8300
8372
  "type": "text",