Compare commits

..

9 Commits

Author SHA1 Message Date
a0c49a29bc feat: multiarch build 2025-10-27 21:38:01 +01:00
06fc5e6747 feat: add Docker build 2025-10-27 15:19:40 +01:00
be6d9bb98a Merge pull request 'feature/gateway' (#2) from feature/gateway into main
Reviewed-on: #2
2025-10-27 13:10:47 +01:00
7784365361 feat: subscribe to websocket for cluster updates 2025-10-27 13:05:35 +01:00
858be416eb feat: use gateway 2025-10-27 09:37:20 +01:00
7b2f600f8c feat: update to new cluster protocol 2025-10-27 07:55:33 +01:00
9d5d0e4d67 feat: improve editor layout 2025-10-12 17:02:47 +02:00
01350ac233 Merge pull request 'feat: pattern editor' (#1) from feature/editor into main
Reviewed-on: #1
2025-10-12 13:55:00 +02:00
bacac57d89 feat: pattern editor 2025-10-12 13:52:22 +02:00
26 changed files with 5832 additions and 229 deletions

View File

@@ -0,0 +1,56 @@
---
description: Guidelines for writing clean, maintainable, and human-readable code. Apply these rules when writing or reviewing code to ensure consistency and quality.
globs:
alwaysApply: true
---
# Clean Code Guidelines
## Constants Over Magic Numbers
- Replace hard-coded values with named constants
- Use descriptive constant names that explain the value's purpose
- Keep constants at the top of the file or in a dedicated constants file
## Meaningful Names
- Variables, functions, and classes should reveal their purpose
- Names should explain why something exists and how it's used
- Avoid abbreviations unless they're universally understood
## Smart Comments
- Don't comment on what the code does - make the code self-documenting
- Use comments to explain why something is done a certain way
- Document APIs, complex algorithms, and non-obvious side effects
## Single Responsibility
- Each function should do exactly one thing
- Functions should be small and focused
- If a function needs a comment to explain what it does, it should be split
## DRY (Don't Repeat Yourself)
- Extract repeated code into reusable functions
- Share common logic through proper abstraction
- Maintain single sources of truth
## Clean Structure
- Keep related code together
- Organize code in a logical hierarchy
- Use consistent file and folder naming conventions
## Encapsulation
- Hide implementation details
- Expose clear interfaces
- Move nested conditionals into well-named functions
## Code Quality Maintenance
- Refactor continuously
- Fix technical debt early
- Leave code cleaner than you found it
## Testing
- Write tests before fixing bugs
- Keep tests readable and maintainable
- Test edge cases and error conditions
## Version Control
- Write clear commit messages
- Make small, focused commits
- Use meaningful branch names

111
.cursor/rules/gitflow.mdc Normal file
View File

@@ -0,0 +1,111 @@
---
description: Gitflow Workflow Rules. These rules should be applied when performing git operations.
---
# Gitflow Workflow Rules
## Main Branches
### main (or master)
- Contains production-ready code
- Never commit directly to main
- Only accepts merges from:
- hotfix/* branches
- release/* branches
- Must be tagged with version number after each merge
### develop
- Main development branch
- Contains latest delivered development changes
- Source branch for feature branches
- Never commit directly to develop
## Supporting Branches
### feature/*
- Branch from: develop
- Merge back into: develop
- Naming convention: feature/[issue-id]-descriptive-name
- Example: feature/123-user-authentication
- Must be up-to-date with develop before creating PR
- Delete after merge
### release/*
- Branch from: develop
- Merge back into:
- main
- develop
- Naming convention: release/vX.Y.Z
- Example: release/v1.2.0
- Only bug fixes, documentation, and release-oriented tasks
- No new features
- Delete after merge
### hotfix/*
- Branch from: main
- Merge back into:
- main
- develop
- Naming convention: hotfix/vX.Y.Z
- Example: hotfix/v1.2.1
- Only for urgent production fixes
- Delete after merge
## Commit Messages
- Format: `type(scope): description`
- Types:
- feat: New feature
- fix: Bug fix
- docs: Documentation changes
- style: Formatting, missing semicolons, etc.
- refactor: Code refactoring
- test: Adding tests
- chore: Maintenance tasks
## Version Control
### Semantic Versioning
- MAJOR version for incompatible API changes
- MINOR version for backwards-compatible functionality
- PATCH version for backwards-compatible bug fixes
## Pull Request Rules
1. All changes must go through Pull Requests
2. Required approvals: minimum 1
3. CI checks must pass
4. No direct commits to protected branches (main, develop)
5. Branch must be up to date before merging
6. Delete branch after merge
## Branch Protection Rules
### main & develop
- Require pull request reviews
- Require status checks to pass
- Require branches to be up to date
- Include administrators in restrictions
- No force pushes
- No deletions
## Release Process
1. Create release branch from develop
2. Bump version numbers
3. Fix any release-specific issues
4. Create PR to main
5. After merge to main:
- Tag release
- Merge back to develop
- Delete release branch
## Hotfix Process
1. Create hotfix branch from main
2. Fix the issue
3. Bump patch version
4. Create PR to main
5. After merge to main:
- Tag release
- Merge back to develop
- Delete hotfix branch

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
.gitignore
.cursor
*.md
node_modules
README.md
docs

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Runtime stage
FROM node:20-alpine
# Install wget for health checks
RUN apk --no-cache add wget
WORKDIR /app
# Copy dependencies from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application files
COPY package*.json ./
COPY server ./server
COPY presets ./presets
COPY public ./public
# Create non-root user (let Alpine assign available GID/UID)
RUN addgroup spore && \
adduser -D -s /bin/sh -G spore spore && \
chown -R spore:spore /app
USER spore
# Expose port
EXPOSE 8080
# Run the application
CMD ["node", "server/index.js"]

54
Makefile Normal file
View File

@@ -0,0 +1,54 @@
.PHONY: install build run clean docker-build docker-run docker-push docker-build-multiarch docker-push-multiarch
# Install dependencies
install:
npm install
# Build the application (if needed)
build: install
# Run the application
run:
node server/index.js
# Start in development mode
dev:
node server/index.js
# Clean build artifacts
clean:
rm -rf node_modules
rm -f package-lock.json
# Docker variables
DOCKER_REGISTRY ?=
IMAGE_NAME = wirelos/spore-ledlab
IMAGE_TAG ?= latest
FULL_IMAGE_NAME = $(if $(DOCKER_REGISTRY),$(DOCKER_REGISTRY)/$(IMAGE_NAME),$(IMAGE_NAME)):$(IMAGE_TAG)
# Build Docker image
docker-build:
docker build -t $(FULL_IMAGE_NAME) .
# Run Docker container
docker-run:
docker run -p 8080:8080 --rm $(FULL_IMAGE_NAME)
# Push Docker image
docker-push:
docker push $(FULL_IMAGE_NAME)
# Build multiarch Docker image
docker-build-multiarch:
docker buildx build --platform linux/amd64,linux/arm64 \
-t $(FULL_IMAGE_NAME) \
--push \
.
# Push multiarch Docker image (if not pushed during build)
docker-push-multiarch:
docker buildx build --platform linux/amd64,linux/arm64 \
-t $(FULL_IMAGE_NAME) \
--push \
.

162
README.md
View File

@@ -3,15 +3,19 @@
LEDLab is a tool for streaming animations to LED matrices connected to SPORE nodes. Create visual effects with real-time parameter control and individual node management.
![ledlab](assets/ledlab.png)
![editor](assets/editor.png)
## Features
- **Multi-Node Management**: Control multiple SPORE nodes individually
- **Real-Time Preview**: See live canvas preview for each node
- **Dynamic Presets**: Choose from 10+ built-in animation presets
- **Visual Preset Editor**: Create custom animations with building blocks
- **Live Parameter Control**: Adjust animation parameters in real-time
- **Auto-Discovery**: Automatically discovers SPORE nodes on the network
- **Modern UI**: Clean, responsive interface with dark/light theme support
- **Import/Export**: Save and share custom presets as JSON files
## Architecture
@@ -19,7 +23,8 @@ LEDLab is a tool for streaming animations to LED matrices connected to SPORE nod
The Node.js server provides the backend for SPORE LEDLab:
- **UDP Discovery**: Listens on port 4210 to automatically discover SPORE nodes
- **Gateway Integration**: Subscribes to spore-gateway WebSocket for real-time cluster updates (requires spore-gateway to be running)
- **UDP Frame Streaming**: Sends animation frames to SPORE nodes via UDP on port 4210
- **WebSocket API**: Real-time bidirectional communication with the web UI
- **Preset Management**: Manages animation presets with configurable parameters
- **Multi-Node Streaming**: Streams different presets to individual nodes simultaneously
@@ -46,6 +51,7 @@ Web UI (Browser) <--WebSocket--> Server <--UDP--> SPORE Nodes
Preset Engine
|
Frame Generation (60fps)
Gateway WebSocket (Real-time cluster updates)
```
## Build
@@ -54,6 +60,7 @@ Web UI (Browser) <--WebSocket--> Server <--UDP--> SPORE Nodes
- Node.js (v14 or higher)
- npm (v6 or higher)
- spore-gateway must be running on port 3001
### Installation
@@ -79,16 +86,22 @@ This will install:
spore-ledlab/
├── server/ # Backend server
│ ├── index.js # Main server & WebSocket handling
── udp-discovery.js # UDP node discovery
── gateway-client.js # Gateway client for node discovery
│ └── udp-discovery.js # UDP sender for frame streaming
├── presets/ # Animation preset implementations
│ ├── preset-registry.js
│ ├── base-preset.js
│ ├── building-blocks.js # Reusable shape/animation components
│ ├── custom-preset.js # Configurable preset engine
│ ├── rainbow-preset.js
│ ├── lava-lamp-preset.js
│ ├── examples/ # Example custom preset JSON files
│ └── ...
├── public/ # Frontend web UI
│ ├── index.html
│ ├── scripts/ # JavaScript modules
│ │ ├── preset-editor.js # Visual preset editor
│ │ └── ...
│ └── styles/ # CSS stylesheets
└── package.json # Project dependencies
```
@@ -108,17 +121,18 @@ npm run dev
```
The server will:
- Start the HTTP server on port 3000
- Start the HTTP server on port 8080 (default)
- Initialize WebSocket server
- Begin UDP discovery on port 4210
- Serve the web UI at http://localhost:3000
- Connect to spore-gateway for node discovery
- Initialize UDP sender for frame streaming
- Serve the web UI at http://localhost:8080
### Access the Web UI
Open your browser and navigate to:
```
http://localhost:3000
http://localhost:8080
```
### Configure SPORE Nodes
@@ -127,8 +141,30 @@ Ensure your SPORE nodes are:
1. Connected to the same network as the LEDLab server
2. Running firmware with PixelStreamController support
3. Listening on UDP port 4210
4. Discovered by spore-gateway
5. Labeled with `app: pixelstream` (LEDLab only displays nodes with this label)
The nodes will automatically appear in the LEDLab grid view once discovered.
The nodes will automatically appear in the LEDLab grid view once they are discovered by spore-gateway and have the `app: pixelstream` label.
### Node Labeling
To display nodes in LEDLab, you must set the `app: pixelstream` label on your SPORE nodes. This can be done via the spore-gateway API or from the SPORE node itself.
**From SPORE Node:**
Nodes should advertise their labels via the UDP heartbeat. The PixelStreamController firmware automatically sets the `app: pixelstream` label.
**Manual via Gateway:**
You can also set labels manually using the spore-gateway API or spore-ui interface.
### Disabling Filtering
To display all nodes without filtering, set the environment variable:
```bash
FILTER_APP_LABEL= npm start
```
Or leave it empty to show all discovered nodes.
## Usage
@@ -181,16 +217,26 @@ In the Settings view:
### Server Configuration
Edit `server/index.js` to modify:
Edit `server/index.js` or use environment variables:
```javascript
const PORT = 3000; // HTTP/WebSocket port
const UDP_PORT = 4210; // UDP discovery port
const PORT = 8080; // HTTP/WebSocket port
const UDP_PORT = 4210; // UDP frame streaming port
const GATEWAY_URL = 'http://localhost:3001'; // spore-gateway URL
const FILTER_APP_LABEL = 'pixelstream'; // Filter nodes by app label
const DEFAULT_FPS = 20; // Default frame rate
const MATRIX_WIDTH = 16; // Default matrix width
const MATRIX_HEIGHT = 16; // Default matrix height
```
Environment variables:
- `PORT` - HTTP server port (default: 8080)
- `UDP_PORT` - UDP port for frame streaming (default: 4210)
- `GATEWAY_URL` - spore-gateway URL (default: http://localhost:3001)
- `FILTER_APP_LABEL` - Filter nodes by app label (default: pixelstream, set to empty string to disable filtering)
- `MATRIX_WIDTH` - Default matrix width (default: 16)
- `MATRIX_HEIGHT` - Default matrix height (default: 16)
### Adding Custom Presets
1. Create a new preset file in `presets/`:
@@ -228,18 +274,103 @@ const MyCustomPreset = require('./my-custom-preset');
registerPreset('my-custom', MyCustomPreset);
```
## Preset Editor
The **🎨 Editor** view allows you to create custom animations by combining reusable building blocks without writing code.
### Building Blocks
#### Shapes
- **Circle**: Round shape with adjustable radius
- **Rectangle**: Four-sided shape with width and height
- **Triangle**: Three-sided shape
- **Blob**: Soft circle with energy field falloff
- **Point**: Single pixel
- **Line**: Line between two points
#### Patterns
- **Trail**: Fade effect on previous frames
- **Radial**: Gradient radiating from center
- **Spiral**: Rotating spiral arms
#### Colors
- **Solid**: Single color
- **Gradient**: Linear blend between two colors
- **Palette**: Multi-stop gradient with custom colors
- **Rainbow**: Color wheel effect
#### Animations
- **Move**: Linear movement from point A to B
- **Rotate**: Rotation around center
- **Pulse**: Scale oscillation (breathing effect)
- **Oscillate X/Y**: Sine wave movement on X or Y axis
- **Fade**: Fade in or out
### Creating a Custom Preset
1. Navigate to the **🎨 Editor** view
2. Enter a name and description
3. Add layers (shapes or patterns):
- Select type and configure properties
- Set position, size, and color
- Add optional animation
- Click ** Add Layer**
4. Edit layers with ✏️, reorder with ▲/▼, or delete with ✕
5. Preview with **▶️ Preview** (requires a selected node from Stream view)
6. **💾 Save** to browser localStorage or **📤 Export JSON** to file
### Example Presets
Import example JSON files from `presets/examples/` to learn:
- **Pulsing Circle**: Rainbow circle with pulse animation
- **Bouncing Squares**: Multiple colored squares oscillating
- **Spiral Rainbow**: Rotating spiral with rainbow gradient
- **Moving Triangle**: Triangle with linear movement
Use **📥 Import JSON** to load examples and customize them.
### JSON Format
Presets are stored as JSON:
```json
{
"name": "My Preset",
"description": "Custom animation",
"layers": [
{
"type": "shape",
"shape": "circle",
"position": { "x": 8, "y": 8 },
"size": { "radius": 4 },
"color": { "type": "solid", "value": "ff0000" },
"animation": {
"type": "pulse",
"params": { "minScale": 0.5, "maxScale": 1.5, "frequency": 1.0 }
}
}
]
}
```
See `presets/examples/README.md` for detailed documentation.
## Troubleshooting
### Nodes Not Appearing
- Ensure spore-gateway is running on port 3001
- Verify nodes are on the same network
- Check firewall settings allow UDP port 4210
- Ensure nodes are running PixelStreamController firmware
- Verify spore-gateway has discovered the nodes
- **IMPORTANT**: Nodes must have the `app: pixelstream` label set. LEDLab only displays nodes with this label. Check node labels in spore-gateway.
### WebSocket Connection Issues
- Check browser console for errors
- Verify server is running on port 3000
- Verify server is running on port 8080 (default)
- Try refreshing the browser
### Animation Not Streaming
@@ -248,13 +379,20 @@ registerPreset('my-custom', MyCustomPreset);
- Choose a preset from the dropdown
- Verify the node shows "streaming" status
### Custom Preset Issues
- **No preview**: Ensure a node is selected from Stream view first
- **Nothing visible**: Check layer has valid position/size values
- **Errors**: Open browser console (F12) for detailed error messages
## Technology Stack
**Backend:**
- Node.js
- Express.js (HTTP server)
- ws (WebSocket server)
- UDP (Node discovery and frame streaming)
- HTTP client (for querying spore-gateway)
- UDP (Frame streaming only)
**Frontend:**
- Vanilla JavaScript (ES6+)

BIN
assets/editor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

250
docs/EDITOR_UPDATE.md Normal file
View File

@@ -0,0 +1,250 @@
# Editor UI Improvements - Update Summary
## Overview
The Preset Editor has been significantly enhanced with live canvas preview, node selection, and real-time rendering capabilities.
## New Features
### 1. Live Canvas Preview
A real-time canvas preview has been added directly to the Editor view:
- **Location**: Center panel, above the layer list
- **Display**: Shows live animation rendering at the configured FPS
- **Updates**: Automatically refreshes when layers are added, modified, or removed
- **FPS Counter**: Displays actual rendering frame rate
- **Size Display**: Shows current matrix dimensions (e.g., "16×16")
**Implementation:**
- `canvas-renderer.js` - Client-side renderer that executes building blocks
- Simplified versions of shapes, colors, and animations for browser rendering
- Efficient pixel-by-pixel drawing with proper serpentine wiring support
### 2. Node Selector Dropdown
A dropdown has been added to the editor header for target selection:
- **Options**:
- "Canvas Preview Only" (default) - Only renders to canvas
- List of discovered nodes with IP addresses
- **Auto-Discovery**: Automatically populates with available nodes
- **Dynamic Updates**: Updates when nodes are discovered or lost
- **Persistent Selection**: Maintains selection when nodes list refreshes
### 3. Dual-Mode Preview
The preview system now supports two modes:
**Canvas-Only Mode** (No node selected):
- Renders animation to canvas preview only
- No network traffic
- Perfect for designing and testing
- Uses client-side rendering engine
**Canvas + Node Mode** (Node selected):
- Renders to both canvas AND transmits to selected node
- Canvas preview for visual feedback
- Node receives frames via server streaming
- Synchronized rendering
### 4. Preview Controls
Enhanced preview control panel:
- **Start/Stop Button**: Toggles preview on/off
- "▶️ Start Preview" when stopped
- "⏸️ Stop Preview" when running
- **Status Indicator**: Shows current mode
- "Stopped" (gray) - Preview is off
- "Canvas Preview" (green) - Rendering to canvas only
- "Streaming to [IP]" (blue) - Rendering to canvas + node
### 5. Auto-Refresh on Changes
The preview automatically restarts when configuration changes:
- Adding a new layer
- Deleting a layer
- Loading a preset
- Importing JSON
- Animation states are reset for clean restart
## Technical Details
### Canvas Renderer (`canvas-renderer.js`)
A browser-based implementation of the building blocks system:
**Features:**
- Renders shapes: circle, rectangle, blob, point
- Color generators: solid, gradient, rainbow
- Animations: move, rotate, pulse, oscillate, fade
- Pattern support: radial, spiral
- Proper RGB color blending
- FPS tracking and display
**Architecture:**
```javascript
class CanvasRenderer {
- setSize(width, height) // Update matrix dimensions
- renderConfiguration(config) // Render preset configuration
- drawFrame(frame) // Draw frame to canvas
- clear() // Clear canvas
- reset() // Reset animation states
}
```
### Preview System Flow
```
User clicks "Start Preview"
1. Initialize canvas renderer
2. Reset animation states
3. Start render loop (20 FPS default)
Every frame:
- Execute building blocks
- Render to canvas
- Update FPS counter
If node selected:
- Also send config to server
- Server streams to node
```
### Node Discovery Integration
The editor subscribes to the main app's event bus:
```javascript
eventBus.subscribe('nodeDiscovered', () => updateDropdown())
eventBus.subscribe('nodeLost', () => updateDropdown())
```
This ensures the node list stays synchronized with the Stream view.
## UI Layout Changes
### Before
```
[Editor Header]
[Sidebar] [Layers] [Details]
```
### After
```
[Editor Header with Preview Controls]
[Sidebar] [Canvas Preview + Layers] [Details]
```
The center panel now features the canvas preview prominently at the top, with the layer list below it.
## Usage Examples
### Example 1: Design Without Hardware
1. Create preset in editor
2. Leave node selector at "Canvas Preview Only"
3. Click "Start Preview"
4. Watch animation in canvas
5. Modify layers and see changes instantly
6. No SPORE node required!
### Example 2: Test on Real Hardware
1. Create preset in editor
2. Select target node from dropdown
3. Click "Start Preview"
4. Animation appears on both canvas and physical LED matrix
5. Modify and see changes on both displays
### Example 3: Quick Iteration
1. Import an example preset
2. Start canvas preview
3. Modify parameters in layer details
4. Changes automatically refresh
5. When satisfied, select node and stream
## Performance
- **Canvas Rendering**: ~60 FPS capable, default 20 FPS
- **Network Efficiency**: Only streams when node is selected
- **CPU Usage**: Minimal, uses requestAnimationFrame equivalent (setInterval)
- **Memory**: Efficient frame buffer management
## Browser Compatibility
- Modern browsers with Canvas API support
- Chrome, Firefox, Safari, Edge (latest versions)
- Mobile browsers supported (responsive design)
## Styling Updates
New CSS classes added:
- `.editor-preview-controls` - Preview control panel
- `.preview-control-group` - Control group container
- `.node-select` - Node selector dropdown
- `.preview-status` - Status indicator
- `.active` - Canvas preview (green)
- `.streaming` - Node streaming (blue)
- `.editor-preview-container` - Canvas container
- `.editor-canvas` - Canvas element
- `.editor-canvas-info` - Size and FPS display
## Files Modified
1. **public/index.html**
- Added preview controls header
- Added canvas preview section
- Removed old preview button
- Added canvas-renderer.js script
2. **public/styles/main.css**
- Added preview control styles (~80 lines)
- Added canvas preview styles
- Enhanced responsive design
- Added status indicator styles
3. **public/scripts/preset-editor.js**
- Added canvas renderer integration
- Added node discovery and dropdown
- Added dual-mode preview system
- Added auto-refresh on changes
- Enhanced preview controls
4. **public/scripts/canvas-renderer.js** (NEW)
- Complete client-side renderer
- Building blocks implementation
- Animation engine
- Canvas drawing logic
## Benefits
**Instant Feedback**: See animations immediately without node
**Faster Iteration**: No need to upload to hardware for testing
**Better UX**: Visual confirmation of changes
**Flexible Testing**: Canvas-only or canvas+node modes
**Resource Efficient**: No network traffic in canvas-only mode
**Educational**: Watch animations render in real-time
**Debugging**: FPS counter helps identify performance issues
## Future Enhancements
Possible future additions:
- [ ] Adjustable preview FPS slider
- [ ] Canvas zoom controls
- [ ] Grid overlay option
- [ ] Pixel coordinates on hover
- [ ] Recording/GIF export
- [ ] Side-by-side canvas comparison
- [ ] Preview size presets (8×8, 16×16, 32×32)
## Conclusion
The enhanced Editor UI provides a professional, feature-rich environment for creating and testing LED animations. The live canvas preview and flexible node targeting make the design process intuitive and efficient, whether working with physical hardware or designing purely in software.

354
docs/PRESET_EDITOR.md Normal file
View File

@@ -0,0 +1,354 @@
# Preset Editor - Implementation Summary
## Overview
A visual preset editor has been added to SPORE LEDLab, allowing users to create custom LED animations by combining reusable building blocks without writing code.
## Components Implemented
### 1. Building Blocks System (`presets/building-blocks.js`)
A comprehensive library of reusable animation components:
#### **Shapes**
- Circle, Rectangle, Triangle, Blob, Point, Line
- Configurable position, size, color, and intensity
- Support for blending and compositing
#### **Transforms**
- Rotate, Scale, Translate
- Transform composition for complex movements
#### **Color Generators**
- Solid, Gradient, Palette (multi-stop), Rainbow, Radial
- HSV color space support
- Programmable color functions
#### **Animations**
- Linear Move, Rotation, Pulse
- Oscillation (X/Y axes)
- Bounce physics, Fade in/out
- Time-based with customizable parameters
#### **Patterns**
- Trail effects (fade decay)
- Energy fields (distance-based intensity)
- Radial patterns
- Spiral patterns
### 2. Custom Preset Engine (`presets/custom-preset.js`)
A JSON-driven preset system that:
- Loads and validates preset configurations
- Manages multiple layers
- Applies animations in real-time
- Supports dynamic parameters
- Renders frames at target FPS
**JSON Schema:**
```json
{
"name": "Preset Name",
"description": "Description",
"layers": [
{
"type": "shape|pattern",
"shape": "circle|rectangle|...",
"position": { "x": 8, "y": 8 },
"size": { "radius": 3 },
"color": { "type": "solid", "value": "ff0000" },
"animation": {
"type": "pulse",
"params": { "minScale": 0.5, "maxScale": 1.5, "frequency": 1.0 }
}
}
],
"parameters": {
"speed": { "type": "range", "min": 0.1, "max": 2.0, "default": 1.0 }
}
}
```
### 3. Visual Editor UI (`public/scripts/preset-editor.js`)
A modern two-panel editor interface:
**Left Panel: Preset Management & Layers**
- Preset metadata (name, description)
- **New preset button** - Creates fresh preset with confirmation
- Add layer button
- Expandable layer list with:
- Visual layer cards showing type and properties
- Layer reordering (up/down arrows)
- Layer deletion
- Click to expand/collapse layer details
- Management section:
- Save/Load presets (localStorage)
- Export/Import JSON files
- Delete current preset
**Right Panel: Preview & Controls**
- Preview controls bar:
- Node selection dropdown (Canvas Only or specific node)
- Start/Stop preview button
- Status indicator
- Canvas size and FPS display
- **Real-time canvas preview** - Shows exact animation as streamed to nodes
- **Synchronized rendering** - Client preview matches server output perfectly
### 4. Server Integration (`server/index.js`)
Added `startCustomPreset` handler:
- Accepts JSON configuration via WebSocket
- Creates CustomPreset instance
- Manages streaming like built-in presets
- Supports all existing features (parameters, node selection, FPS control)
### 5. UI Integration (`public/index.html`, `public/styles/main.css`)
- Added Editor tab to navigation
- Modern two-panel responsive layout
- Dark/light theme support with **enhanced dropdown styling**
- **Dark dropdown backgrounds** with light text for better readability
- Notification system for user feedback
- Import/Export file handling
- **Canvas renderer** (`public/scripts/canvas-renderer.js`) for client-side preview
### 6. Example Presets (`presets/examples/`)
Four complete example presets:
1. **Pulsing Circle** - Rainbow circle with pulse animation
2. **Bouncing Squares** - Multiple colored squares with oscillation
3. **Spiral Rainbow** - Rotating spiral with multi-color gradient
4. **Moving Triangle** - Linear movement with gradient colors
Plus comprehensive README documentation.
## Features
**Visual Composition**
- Drag-free layer management with expandable cards
- Real-time property editing
- Layer reordering and deletion
- **Triangle shape support** - Full triangle rendering with rotation
**Flexible Configuration**
- Multiple shape types (circle, rectangle, triangle, blob, point)
- Various color modes (solid, gradient, palette, rainbow)
- Rich animation options (move, rotate, pulse, oscillate, fade)
- Parameter customization
- **Pattern layers** with intensity control
**Save & Share**
- LocalStorage persistence
- JSON export/import
- Version-controllable configurations
- Shareable preset files
- **New preset button** for fresh starts
**Live Preview**
- **Synchronized canvas preview** - Shows exact animation as streamed to nodes
- Real-time streaming to selected node
- Integrated with existing node selection
- Full parameter control
- **Layer compositing** - All layers render correctly with proper alpha blending
**Reusable Components**
- Extracted from existing presets
- Optimized rendering
- Extensible architecture
- **Server-client rendering parity** - Identical output on both sides
## Usage Flow
1. User opens **🎨 Editor** view
2. Enters preset name and description
3. Adds layers:
- Clicks " Add Layer" button
- Configures layer properties in expandable cards:
- Shape type (circle, rectangle, triangle, blob, point)
- Position (X, Y coordinates)
- Size (radius, width, height)
- Color (solid, gradient, palette, rainbow)
- Intensity (0.0 - 1.0)
- Animation (move, rotate, pulse, oscillate, fade)
4. Manages layers:
- Expands/collapses layer details by clicking
- Reorders with up/down arrows
- Deletes unwanted layers
5. Previews animation:
- Selects target node or "Canvas Only"
- Clicks "▶️ Start" to begin preview
- Watches synchronized canvas preview
6. Saves work:
- Uses "💾 Save" to store in localStorage
- Uses "📤 Export JSON" to download file
- Uses "📥 Import JSON" to load shared presets
7. Creates new presets:
- Uses "📄 New" button for fresh start
- Confirms if current work will be lost
## Technical Architecture
```
┌─────────────────────────────────────────┐
│ Editor UI (Browser) │
│ ┌────────────────────────────────────┐ │
│ │ Preset Editor Component │ │
│ │ - Layer Management │ │
│ │ - Property Forms │ │
│ │ - Preview Control │ │
│ └────────────────────────────────────┘ │
└──────────────┬──────────────────────────┘
│ WebSocket (JSON Config)
┌─────────────────────────────────────────┐
│ Server (Node.js) │
│ ┌────────────────────────────────────┐ │
│ │ startCustomPreset Handler │ │
│ │ - Parse JSON configuration │ │
│ │ - Create CustomPreset instance │ │
│ │ - Start streaming │ │
│ └────────────────────────────────────┘ │
└──────────────┬──────────────────────────┘
│ Frame Generation Loop
┌─────────────────────────────────────────┐
│ Custom Preset Engine │
│ ┌────────────────────────────────────┐ │
│ │ Building Blocks System │ │
│ │ - Shapes, Patterns, Colors │ │
│ │ - Animations, Transforms │ │
│ │ - Composition & Rendering │ │
│ └────────────────────────────────────┘ │
└──────────────┬──────────────────────────┘
│ UDP Frames
[SPORE Node LED Matrix]
```
## File Changes Summary
### New Files
- `presets/building-blocks.js` - Core building blocks library
- `presets/custom-preset.js` - JSON-driven preset engine
- `public/scripts/preset-editor.js` - Visual editor UI
- `public/scripts/canvas-renderer.js` - Client-side canvas renderer
- `presets/examples/pulsing-circle.json` - Example preset
- `presets/examples/bouncing-squares.json` - Example preset
- `presets/examples/spiral-rainbow.json` - Example preset
- `presets/examples/moving-triangle.json` - Example preset
- `presets/examples/README.md` - Example documentation
- `PRESET_EDITOR.md` - This file
### Modified Files
- `public/index.html` - Added Editor view HTML with New button
- `public/styles/main.css` - Added editor styles and enhanced dropdown styling
- `server/index.js` - Added custom preset handler
- `presets/building-blocks.js` - Added intensity support for pattern layers
- `presets/custom-preset.js` - Added intensity support for pattern rendering
- `README.md` - Added Preset Editor documentation
## Extensibility
The system is designed to be easily extended:
**Add New Shape:**
```javascript
Shapes.star = (frame, width, height, centerX, centerY, points, radius, color) => {
// Rendering logic
};
```
**Add New Animation:**
```javascript
Animations.wobble = (amplitude, frequency) => {
return () => {
// Animation logic
};
};
```
**Add New Color Generator:**
```javascript
ColorGenerators.plasma = (time, x, y) => {
// Color calculation
};
```
## Performance
- Efficient rendering with minimal allocations
- Frame generation at configurable FPS (1-60)
- Optimized color blending
- Smart update scheduling
## Browser Compatibility
- Modern browsers (Chrome, Firefox, Safari, Edge)
- Requires ES6+ support
- LocalStorage for persistence
- File API for import/export
## Recent Updates
### Latest Improvements (Current Version)
-**Triangle Shape Support** - Added full triangle rendering with rotation animation
-**Layer Compositing Fix** - All layers now render correctly with proper alpha blending
-**Server-Client Synchronization** - Preview canvas shows identical animation to streamed output
-**Pattern Layer Intensity** - Pattern layers now support intensity control like shape layers
-**New Preset Button** - Quick way to start fresh with confirmation dialog
-**Enhanced Dropdown Styling** - Dark backgrounds with light text for better readability
-**Canvas Renderer** - Dedicated client-side renderer for accurate previews
### Bug Fixes
- Fixed pattern layer overwriting instead of compositing with other layers
- Fixed gradient color calculation bug in client-side renderer
- Fixed triangle rendering missing from preview canvas
- Improved dropdown readability in dark theme
## Future Enhancements
Possible future additions:
- [ ] Undo/Redo support
- [ ] Layer groups
- [ ] Custom parameter types
- [ ] Animation timeline editor
- [ ] Blend modes between layers
- [ ] Physics simulations
- [ ] Sound-reactive parameters
- [ ] Community preset library
- [ ] Preset thumbnails/previews
- [ ] Keyboard shortcuts
- [ ] Line shape support
- [ ] Additional pattern types
- [ ] Real-time collaboration
## Testing
To test the implementation:
1. Start LEDLab server: `npm start`
2. Open browser to `http://localhost:3000`
3. Navigate to Stream view and select a node
4. Switch to Editor view
5. Import an example preset (e.g., `pulsing-circle.json`)
6. Click Preview to see it running
7. Modify parameters and preview again
8. Create your own preset from scratch
9. Export and reimport to verify JSON handling
## Conclusion
The Preset Editor provides a powerful, user-friendly way to create custom LED animations without coding. It leverages a well-architected building blocks system that extracts reusable patterns from existing presets and makes them composable through a visual interface and JSON configuration.
**Key achievements:**
- **Perfect synchronization** between preview and actual output
- **Comprehensive shape support** including triangles with rotation
- **Proper layer compositing** with alpha blending
- **Intuitive UI** with expandable layer cards and dark theme
- **Robust persistence** with localStorage and JSON export/import
- **Real-time preview** that matches server rendering exactly
The editor is now production-ready with all major rendering issues resolved and enhanced user experience features implemented.

119
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"dgram": "^1.0.1",
"axios": "^1.6.0",
"express": "^4.18.2",
"ws": "^8.14.2"
}
@@ -33,6 +33,23 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -95,6 +112,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -140,6 +169,15 @@
"ms": "2.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -159,13 +197,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dgram": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dgram/-/dgram-1.0.1.tgz",
"integrity": "sha512-zJVFL1EWfKtE0z2VN6qfpn/a+qG1viEzcwJA0EjtzS76ONSE3sEyWBwEbo32hS4IFw/EWVuWN+8b89aPW6It2A==",
"deprecated": "npm is holding this package for security reasons. As it's a core Node module, we will not transfer it over to other users. You may safely remove the package from your dependencies.",
"license": "ISC"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -225,6 +256,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -304,6 +350,42 @@
"node": ">= 0.8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -392,6 +474,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -583,6 +680,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",

494
presets/building-blocks.js Normal file
View File

@@ -0,0 +1,494 @@
// Building Blocks for Custom Presets
// Extracted reusable components from existing presets
const { hexToRgb, rgbToHex, lerpRgb, clamp, toIndex, samplePalette } = require('./frame-utils');
/**
* Building Block Categories:
* 1. Shapes - Draw geometric shapes
* 2. Transforms - Position/scale/rotate operations
* 3. Color Generators - Generate colors based on various algorithms
* 4. Animations - Time-based modifications
* 5. Compositors - Combine multiple elements
*/
// ==================== SHAPES ====================
const Shapes = {
/**
* Draw a circle at a position with radius
*/
circle: (frame, width, height, centerX, centerY, radius, color, intensity = 1.0) => {
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const dx = col - centerX;
const dy = row - centerY;
const distance = Math.hypot(dx, dy);
if (distance <= radius) {
const falloff = 1 - (distance / radius);
const pixelIntensity = falloff * intensity;
setPixelColor(frame, col, row, width, color, pixelIntensity);
}
}
}
},
/**
* Draw a rectangle
*/
rectangle: (frame, width, height, x, y, rectWidth, rectHeight, color, intensity = 1.0) => {
for (let row = Math.floor(y); row < Math.min(height, y + rectHeight); row++) {
for (let col = Math.floor(x); col < Math.min(width, x + rectWidth); col++) {
if (row >= 0 && col >= 0) {
setPixelColor(frame, col, row, width, color, intensity);
}
}
}
},
/**
* Draw a line from point A to point B
*/
line: (frame, width, height, x1, y1, x2, y2, color, thickness = 1, intensity = 1.0) => {
const dx = x2 - x1;
const dy = y2 - y1;
const distance = Math.hypot(dx, dy);
const steps = Math.ceil(distance);
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const x = x1 + dx * t;
const y = y1 + dy * t;
// Draw with thickness
for (let ty = -thickness / 2; ty <= thickness / 2; ty++) {
for (let tx = -thickness / 2; tx <= thickness / 2; tx++) {
const px = Math.round(x + tx);
const py = Math.round(y + ty);
if (px >= 0 && px < width && py >= 0 && py < height) {
setPixelColor(frame, px, py, width, color, intensity);
}
}
}
}
},
/**
* Draw a triangle
*/
triangle: (frame, width, height, x1, y1, x2, y2, x3, y3, color, intensity = 1.0) => {
// Simple filled triangle using barycentric coordinates
const minX = Math.max(0, Math.floor(Math.min(x1, x2, x3)));
const maxX = Math.min(width - 1, Math.ceil(Math.max(x1, x2, x3)));
const minY = Math.max(0, Math.floor(Math.min(y1, y2, y3)));
const maxY = Math.min(height - 1, Math.ceil(Math.max(y1, y2, y3)));
for (let row = minY; row <= maxY; row++) {
for (let col = minX; col <= maxX; col++) {
if (isPointInTriangle(col, row, x1, y1, x2, y2, x3, y3)) {
setPixelColor(frame, col, row, width, color, intensity);
}
}
}
},
/**
* Draw a single pixel/point
*/
point: (frame, width, height, x, y, color, intensity = 1.0) => {
const col = Math.round(x);
const row = Math.round(y);
if (col >= 0 && col < width && row >= 0 && row < height) {
setPixelColor(frame, col, row, width, color, intensity);
}
},
/**
* Draw a blob (soft circle with energy field)
*/
blob: (frame, width, height, centerX, centerY, radius, color, falloffPower = 2) => {
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const dx = col - centerX;
const dy = row - centerY;
const distance = Math.hypot(dx, dy);
const falloff = Math.max(0, 1 - distance / radius);
const intensity = Math.pow(falloff, falloffPower);
if (intensity > 0.01) {
setPixelColor(frame, col, row, width, color, intensity);
}
}
}
},
};
// ==================== TRANSFORMS ====================
const Transforms = {
/**
* Rotate a point around a center
*/
rotate: (x, y, centerX, centerY, angle) => {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const dx = x - centerX;
const dy = y - centerY;
return {
x: centerX + dx * cos - dy * sin,
y: centerY + dx * sin + dy * cos,
};
},
/**
* Scale a point from a center
*/
scale: (x, y, centerX, centerY, scaleX, scaleY) => {
return {
x: centerX + (x - centerX) * scaleX,
y: centerY + (y - centerY) * scaleY,
};
},
/**
* Translate a point
*/
translate: (x, y, dx, dy) => {
return {
x: x + dx,
y: y + dy,
};
},
/**
* Apply multiple transforms in sequence
*/
compose: (x, y, transforms) => {
let point = { x, y };
for (const transform of transforms) {
point = transform(point.x, point.y);
}
return point;
},
};
// ==================== COLOR GENERATORS ====================
const ColorGenerators = {
/**
* Solid color
*/
solid: (color) => () => color,
/**
* Linear gradient between two colors
*/
gradient: (color1, color2) => (t) => {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
return rgbToHex(lerpRgb(rgb1, rgb2, clamp(t, 0, 1)));
},
/**
* Multi-stop gradient (palette)
*/
palette: (colorStops) => (t) => {
const paletteStops = colorStops.map(stop => ({
stop: stop.position,
color: hexToRgb(stop.color),
}));
return samplePalette(paletteStops, clamp(t, 0, 1));
},
/**
* Rainbow color wheel
*/
rainbow: () => (t) => {
const pos = Math.floor(clamp(t, 0, 1) * 255);
let r, g, b;
const wheelPos = 255 - pos;
if (wheelPos < 85) {
r = 255 - wheelPos * 3;
g = 0;
b = wheelPos * 3;
} else if (wheelPos < 170) {
const adjusted = wheelPos - 85;
r = 0;
g = adjusted * 3;
b = 255 - adjusted * 3;
} else {
const adjusted = wheelPos - 170;
r = adjusted * 3;
g = 255 - adjusted * 3;
b = 0;
}
return rgbToHex({ r, g, b });
},
/**
* HSV to RGB color generation
*/
hsv: (hue, saturation = 1.0, value = 1.0) => () => {
const h = hue / 60;
const c = value * saturation;
const x = c * (1 - Math.abs((h % 2) - 1));
const m = value - c;
let r, g, b;
if (h < 1) {
[r, g, b] = [c, x, 0];
} else if (h < 2) {
[r, g, b] = [x, c, 0];
} else if (h < 3) {
[r, g, b] = [0, c, x];
} else if (h < 4) {
[r, g, b] = [0, x, c];
} else if (h < 5) {
[r, g, b] = [x, 0, c];
} else {
[r, g, b] = [c, 0, x];
}
return rgbToHex({
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255),
});
},
/**
* Radial gradient from center
*/
radial: (color1, color2, centerX, centerY, maxRadius) => (x, y) => {
const dx = x - centerX;
const dy = y - centerY;
const distance = Math.hypot(dx, dy);
const t = clamp(distance / maxRadius, 0, 1);
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
return rgbToHex(lerpRgb(rgb1, rgb2, t));
},
};
// ==================== ANIMATIONS ====================
const Animations = {
/**
* Linear movement
*/
linearMove: (startX, startY, endX, endY, duration) => {
const startTime = Date.now();
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const t = clamp((elapsed % duration) / duration, 0, 1);
return {
x: startX + (endX - startX) * t,
y: startY + (endY - startY) * t,
t,
};
};
},
/**
* Oscillating movement (sine wave)
*/
oscillate: (center, amplitude, frequency, phase = 0) => {
const startTime = Date.now();
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const value = center + amplitude * Math.sin(2 * Math.PI * frequency * elapsed + phase);
return { value, elapsed };
};
},
/**
* Rotation animation
*/
rotation: (speed) => {
const startTime = Date.now();
return () => {
const elapsed = (Date.now() - startTime) / 1000;
return { angle: elapsed * speed * Math.PI * 2, elapsed };
};
},
/**
* Pulsing animation (scale)
*/
pulse: (minScale, maxScale, frequency) => {
const startTime = Date.now();
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const t = (Math.sin(2 * Math.PI * frequency * elapsed) + 1) / 2;
return { scale: minScale + (maxScale - minScale) * t, t, elapsed };
};
},
/**
* Bounce physics
*/
bounce: (position, velocity, bounds) => {
let pos = position;
let vel = velocity;
return (dt) => {
pos += vel * dt;
if (pos < bounds.min) {
pos = bounds.min + (bounds.min - pos);
vel = Math.abs(vel);
} else if (pos > bounds.max) {
pos = bounds.max - (pos - bounds.max);
vel = -Math.abs(vel);
}
return { position: pos, velocity: vel };
};
},
/**
* Fade in/out animation
*/
fade: (duration, fadeIn = true) => {
const startTime = Date.now();
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const t = clamp(elapsed / duration, 0, 1);
return { intensity: fadeIn ? t : (1 - t), t, elapsed };
};
},
};
// ==================== PATTERNS ====================
const Patterns = {
/**
* Trail effect (fade previous frames)
*/
trail: (frame, decayFactor = 0.8) => {
for (let i = 0; i < frame.length; i++) {
const rgb = hexToRgb(frame[i]);
frame[i] = rgbToHex({
r: Math.round(rgb.r * decayFactor),
g: Math.round(rgb.g * decayFactor),
b: Math.round(rgb.b * decayFactor),
});
}
},
/**
* Energy field (distance-based intensity)
*/
energyField: (frame, width, height, points, colorGenerator) => {
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
let totalEnergy = 0;
points.forEach(point => {
const dx = col - point.x;
const dy = row - point.y;
const distance = Math.hypot(dx, dy);
const falloff = Math.max(0, 1 - distance / point.radius);
totalEnergy += point.intensity * Math.pow(falloff, 2);
});
const energy = clamp(totalEnergy, 0, 1);
const color = colorGenerator(energy);
frame[toIndex(col, row, width)] = color;
}
}
},
/**
* Radial pattern from center
*/
radial: (frame, width, height, centerX, centerY, colorGenerator, intensity = 1.0) => {
const maxRadius = Math.hypot(width / 2, height / 2);
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const dx = col - centerX;
const dy = row - centerY;
const distance = Math.hypot(dx, dy);
const t = clamp(distance / maxRadius, 0, 1);
const color = colorGenerator(t);
setPixelColor(frame, col, row, width, color, intensity);
}
}
},
/**
* Angular/spiral pattern
*/
spiral: (frame, width, height, centerX, centerY, arms, rotation, colorGenerator, intensity = 1.0) => {
const maxRadius = Math.hypot(width / 2, height / 2);
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const dx = col - centerX;
const dy = row - centerY;
const distance = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx);
const spiralValue = (Math.sin(arms * (angle + rotation)) + 1) / 2;
const radiusValue = distance / maxRadius;
const t = clamp(spiralValue * 0.5 + radiusValue * 0.5, 0, 1);
const color = colorGenerator(t);
setPixelColor(frame, col, row, width, color, intensity);
}
}
},
};
// ==================== HELPER FUNCTIONS ====================
function setPixelColor(frame, col, row, width, color, intensity = 1.0) {
const index = toIndex(col, row, width);
if (intensity >= 1.0) {
frame[index] = color;
} else {
const currentRgb = hexToRgb(frame[index]);
const newRgb = hexToRgb(color);
const blended = {
r: Math.round(currentRgb.r + (newRgb.r - currentRgb.r) * intensity),
g: Math.round(currentRgb.g + (newRgb.g - currentRgb.g) * intensity),
b: Math.round(currentRgb.b + (newRgb.b - currentRgb.b) * intensity),
};
frame[index] = rgbToHex(blended);
}
}
function isPointInTriangle(px, py, x1, y1, x2, y2, x3, y3) {
const d1 = sign(px, py, x1, y1, x2, y2);
const d2 = sign(px, py, x2, y2, x3, y3);
const d3 = sign(px, py, x3, y3, x1, y1);
const hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
const hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
return !(hasNeg && hasPos);
}
function sign(px, py, x1, y1, x2, y2) {
return (px - x2) * (y1 - y2) - (x1 - x2) * (py - y2);
}
module.exports = {
Shapes,
Transforms,
ColorGenerators,
Animations,
Patterns,
};

417
presets/custom-preset.js Normal file
View File

@@ -0,0 +1,417 @@
// Custom Preset - A configurable preset based on JSON configuration
const BasePreset = require('./base-preset');
const { createFrame } = require('./frame-utils');
const { Shapes, Transforms, ColorGenerators, Animations, Patterns } = require('./building-blocks');
/**
* Custom Preset Configuration Schema:
*
* {
* "name": "My Custom Preset",
* "description": "Description of the preset",
* "layers": [
* {
* "type": "shape",
* "shape": "circle|rectangle|triangle|line|point|blob",
* "position": { "x": 8, "y": 8 },
* "size": { "width": 5, "height": 5, "radius": 3 },
* "color": {
* "type": "solid|gradient|palette|rainbow|radial",
* "value": "#ff0000",
* "stops": [...]
* },
* "animation": {
* "type": "move|rotate|scale|pulse|fade|bounce",
* "params": { ... }
* },
* "blendMode": "normal|add|multiply"
* },
* {
* "type": "pattern",
* "pattern": "trail|energyField|radial|spiral",
* "params": { ... }
* }
* ],
* "parameters": {
* "speed": { "type": "range", "min": 0.1, "max": 2.0, "default": 1.0 },
* ...
* }
* }
*/
class CustomPreset extends BasePreset {
constructor(width = 16, height = 16, configuration = null) {
super(width, height);
this.configuration = configuration || this.getDefaultConfiguration();
this.animationStates = new Map();
this.time = 0;
this.lastFrameTime = Date.now();
this.initializeParameters();
this.initializeAnimations();
}
getDefaultConfiguration() {
return {
name: 'Custom Preset',
description: 'A configurable preset',
layers: [],
parameters: {
speed: { type: 'range', min: 0.1, max: 2.0, step: 0.1, default: 1.0 },
brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 },
},
};
}
setConfiguration(configuration) {
this.configuration = configuration;
this.initializeParameters();
this.initializeAnimations();
}
initializeParameters() {
this.defaultParameters = {};
if (this.configuration.parameters) {
Object.entries(this.configuration.parameters).forEach(([name, config]) => {
this.defaultParameters[name] = config.default;
});
}
this.resetToDefaults();
}
initializeAnimations() {
this.animationStates.clear();
this.configuration.layers?.forEach((layer, index) => {
if (layer.animation) {
const animation = this.createAnimation(layer.animation);
this.animationStates.set(index, animation);
}
});
}
createAnimation(animConfig) {
const type = animConfig.type;
const params = animConfig.params || {};
switch (type) {
case 'move':
return Animations.linearMove(
params.startX || 0,
params.startY || 0,
params.endX || this.width,
params.endY || this.height,
params.duration || 2.0
);
case 'rotate':
return Animations.rotation(params.speed || 1.0);
case 'pulse':
return Animations.pulse(
params.minScale || 0.5,
params.maxScale || 1.5,
params.frequency || 1.0
);
case 'fade':
return Animations.fade(
params.duration || 2.0,
params.fadeIn !== false
);
case 'oscillateX':
return Animations.oscillate(
params.center || this.width / 2,
params.amplitude || this.width / 4,
params.frequency || 0.5,
params.phase || 0
);
case 'oscillateY':
return Animations.oscillate(
params.center || this.height / 2,
params.amplitude || this.height / 4,
params.frequency || 0.5,
params.phase || 0
);
default:
return () => ({});
}
}
createColorGenerator(colorConfig) {
if (!colorConfig) {
return ColorGenerators.solid('ff0000');
}
const type = colorConfig.type || 'solid';
switch (type) {
case 'solid':
return ColorGenerators.solid(colorConfig.value || 'ff0000');
case 'gradient':
return ColorGenerators.gradient(
colorConfig.color1 || 'ff0000',
colorConfig.color2 || '0000ff'
);
case 'palette':
return ColorGenerators.palette(colorConfig.stops || [
{ position: 0, color: '000000' },
{ position: 1, color: 'ffffff' }
]);
case 'rainbow':
return ColorGenerators.rainbow();
case 'radial':
return ColorGenerators.radial(
colorConfig.color1 || 'ff0000',
colorConfig.color2 || '0000ff',
colorConfig.centerX || this.width / 2,
colorConfig.centerY || this.height / 2,
colorConfig.maxRadius || Math.hypot(this.width / 2, this.height / 2)
);
default:
return ColorGenerators.solid('ff0000');
}
}
renderFrame() {
const now = Date.now();
const deltaTime = (now - this.lastFrameTime) / 1000;
this.lastFrameTime = now;
const speed = this.getParameter('speed') || 1.0;
const brightness = this.getParameter('brightness') || 1.0;
this.time += deltaTime * speed;
let frame = createFrame(this.width, this.height, '000000');
// Render each layer
this.configuration.layers?.forEach((layer, index) => {
this.renderLayer(frame, layer, index, brightness);
});
return frame;
}
renderLayer(frame, layer, layerIndex, globalBrightness) {
const type = layer.type;
if (type === 'shape') {
this.renderShape(frame, layer, layerIndex, globalBrightness);
} else if (type === 'pattern') {
this.renderPattern(frame, layer, layerIndex, globalBrightness);
}
}
renderShape(frame, layer, layerIndex, globalBrightness) {
let position = layer.position || { x: this.width / 2, y: this.height / 2 };
let size = layer.size || { radius: 3 };
let rotation = 0;
let scale = 1.0;
let intensity = (layer.intensity || 1.0) * globalBrightness;
// Apply animation
if (this.animationStates.has(layerIndex)) {
const animation = this.animationStates.get(layerIndex);
const animState = animation();
if (animState.x !== undefined && animState.y !== undefined) {
position = { x: animState.x, y: animState.y };
}
if (animState.angle !== undefined) {
rotation = animState.angle;
}
if (animState.scale !== undefined) {
scale = animState.scale;
}
if (animState.intensity !== undefined) {
intensity *= animState.intensity;
}
if (animState.value !== undefined && layer.animation?.axis === 'x') {
position.x = animState.value;
} else if (animState.value !== undefined && layer.animation?.axis === 'y') {
position.y = animState.value;
}
}
// Create color generator
const colorGen = this.createColorGenerator(layer.color);
const color = typeof colorGen === 'function' ? colorGen(0.5) : colorGen;
// Apply scale to size
const scaledSize = {
radius: (size.radius || 3) * scale,
width: (size.width || 5) * scale,
height: (size.height || 5) * scale,
};
// Render shape
const shapeType = layer.shape || 'circle';
switch (shapeType) {
case 'circle':
Shapes.circle(
frame,
this.width,
this.height,
position.x,
position.y,
scaledSize.radius,
color,
intensity
);
break;
case 'rectangle':
Shapes.rectangle(
frame,
this.width,
this.height,
position.x - scaledSize.width / 2,
position.y - scaledSize.height / 2,
scaledSize.width,
scaledSize.height,
color,
intensity
);
break;
case 'blob':
Shapes.blob(
frame,
this.width,
this.height,
position.x,
position.y,
scaledSize.radius,
color,
layer.falloffPower || 2
);
break;
case 'point':
Shapes.point(
frame,
this.width,
this.height,
position.x,
position.y,
color,
intensity
);
break;
case 'triangle':
// Triangle with rotation
const triSize = scaledSize.radius || 3;
const points = [
{ x: 0, y: -triSize },
{ x: -triSize, y: triSize },
{ x: triSize, y: triSize }
];
const rotated = points.map(p =>
Transforms.rotate(p.x, p.y, 0, 0, rotation)
);
Shapes.triangle(
frame,
this.width,
this.height,
position.x + rotated[0].x,
position.y + rotated[0].y,
position.x + rotated[1].x,
position.y + rotated[1].y,
position.x + rotated[2].x,
position.y + rotated[2].y,
color,
intensity
);
break;
case 'line':
const lineParams = layer.lineParams || {};
Shapes.line(
frame,
this.width,
this.height,
lineParams.x1 || position.x,
lineParams.y1 || position.y,
lineParams.x2 || position.x + 5,
lineParams.y2 || position.y + 5,
color,
lineParams.thickness || 1,
intensity
);
break;
}
}
renderPattern(frame, layer, layerIndex, globalBrightness) {
const patternType = layer.pattern;
const params = layer.params || {};
const intensity = (layer.intensity || 1.0) * globalBrightness;
switch (patternType) {
case 'trail':
Patterns.trail(frame, params.decayFactor || 0.8);
break;
case 'radial':
const radialColorGen = this.createColorGenerator(layer.color);
Patterns.radial(
frame,
this.width,
this.height,
params.centerX || this.width / 2,
params.centerY || this.height / 2,
radialColorGen,
intensity
);
break;
case 'spiral':
const spiralColorGen = this.createColorGenerator(layer.color);
Patterns.spiral(
frame,
this.width,
this.height,
params.centerX || this.width / 2,
params.centerY || this.height / 2,
params.arms || 5,
this.time * (params.rotationSpeed || 1.0),
spiralColorGen,
intensity
);
break;
}
}
getMetadata() {
return {
name: this.configuration.name || 'Custom Preset',
description: this.configuration.description || 'A custom configurable preset',
parameters: this.configuration.parameters || {},
width: this.width,
height: this.height,
};
}
}
module.exports = CustomPreset;

146
presets/examples/README.md Normal file
View File

@@ -0,0 +1,146 @@
# Example Custom Presets
This directory contains example custom preset configurations that demonstrate the capabilities of the LEDLab Preset Editor.
## Available Examples
### 1. Pulsing Circle (`pulsing-circle.json`)
A simple preset featuring a single circle at the center that pulses in and out using rainbow colors.
**Features:**
- Rainbow color generation
- Pulse animation (scale oscillation)
- Centered positioning
### 2. Bouncing Squares (`bouncing-squares.json`)
Multiple colored rectangles bouncing around the matrix in different directions.
**Features:**
- Multiple layers with different shapes
- Oscillating animations on X and Y axes
- Different colors per layer
- Phase offsets for varied motion
### 3. Spiral Rainbow (`spiral-rainbow.json`)
A rotating spiral pattern with a full rainbow gradient.
**Features:**
- Pattern layer (spiral)
- Multi-stop color palette
- Configurable rotation speed and arm count
### 4. Moving Triangle (`moving-triangle.json`)
A triangle that moves linearly across the screen with a gradient color scheme.
**Features:**
- Triangle shape
- Linear movement animation
- Gradient colors
## How to Use
1. Open the LEDLab application
2. Navigate to the **🎨 Editor** view
3. Click the **📥 Import JSON** button
4. Select one of the example JSON files
5. Review the configuration in the editor
6. Click **▶️ Preview** to test on a selected node
7. Modify parameters to customize
8. Click **💾 Save** to store your customized version
## Creating Your Own Presets
Use these examples as templates for creating your own custom presets. The general structure is:
```json
{
"name": "Preset Name",
"description": "Description of what the preset does",
"layers": [
{
"type": "shape" | "pattern",
"shape": "circle" | "rectangle" | "triangle" | "blob" | "point" | "line",
"pattern": "trail" | "radial" | "spiral",
"position": { "x": 8, "y": 8 },
"size": { "radius": 3, "width": 5, "height": 5 },
"color": {
"type": "solid" | "gradient" | "palette" | "rainbow" | "radial",
"value": "hexcolor",
...
},
"animation": {
"type": "move" | "rotate" | "pulse" | "oscillateX" | "oscillateY" | "fade",
"params": { ... }
}
}
],
"parameters": {
"speed": { "type": "range", "min": 0.1, "max": 2.0, "step": 0.1, "default": 1.0 },
"brightness": { "type": "range", "min": 0.1, "max": 1.0, "step": 0.1, "default": 1.0 }
}
}
```
## Building Blocks Reference
### Shape Types
- **circle**: Round shape with radius
- **rectangle**: Four-sided shape with width and height
- **triangle**: Three-sided shape with radius (determines size)
- **blob**: Soft circle with energy field falloff
- **point**: Single pixel
- **line**: Line between two points
### Pattern Types
- **trail**: Fade effect on previous frames
- **radial**: Gradient radiating from center
- **spiral**: Rotating spiral arms
### Color Types
- **solid**: Single color
- **gradient**: Linear blend between two colors
- **palette**: Multi-stop gradient
- **rainbow**: Color wheel effect
- **radial**: Gradient from center outward
### Animation Types
- **move**: Linear movement from point A to B
- **rotate**: Rotation around center
- **pulse**: Scale oscillation
- **oscillateX/Y**: Sine wave movement on X or Y axis
- **fade**: Fade in or out
## Tips
1. **Layer Order Matters**: Layers are rendered in order, so later layers appear on top
2. **Combine Multiple Shapes**: Create complex effects by layering shapes with different animations
3. **Use Phase Offsets**: For oscillating animations, use different phase values to desynchronize motion
4. **Experiment with Colors**: Try different color combinations and gradients
5. **Start Simple**: Begin with one layer and gradually add complexity
6. **Test Early**: Preview frequently to see how your changes look in real-time
## Advanced Techniques
### Creating Trails
Add a trail pattern layer with high decay factor combined with moving shapes:
```json
{
"type": "pattern",
"pattern": "trail",
"params": {
"decayFactor": 0.85
}
}
```
### Synchronized Motion
Use the same frequency and phase for oscillating animations to create synchronized movement.
### Color Cycling
Use the rainbow color type with pulse or rotate animations for dynamic color effects.
## Need Help?
Refer to the main LEDLab documentation or experiment in the editor. The preview function lets you see changes in real-time!

View File

@@ -0,0 +1,100 @@
{
"name": "Bouncing Squares",
"description": "Multiple colored squares bouncing around",
"layers": [
{
"type": "shape",
"shape": "rectangle",
"position": {
"x": 8,
"y": 8
},
"size": {
"width": 3,
"height": 3
},
"color": {
"type": "solid",
"value": "ff0000"
},
"intensity": 1.0,
"animation": {
"type": "oscillateX",
"axis": "x",
"params": {
"center": 8,
"amplitude": 6,
"frequency": 0.5,
"phase": 0
}
}
},
{
"type": "shape",
"shape": "rectangle",
"position": {
"x": 8,
"y": 8
},
"size": {
"width": 3,
"height": 3
},
"color": {
"type": "solid",
"value": "00ff00"
},
"intensity": 1.0,
"animation": {
"type": "oscillateY",
"axis": "y",
"params": {
"center": 8,
"amplitude": 6,
"frequency": 0.7,
"phase": 1.57
}
}
},
{
"type": "shape",
"shape": "rectangle",
"position": {
"x": 8,
"y": 8
},
"size": {
"width": 2,
"height": 2
},
"color": {
"type": "solid",
"value": "0000ff"
},
"intensity": 1.0,
"animation": {
"type": "rotate",
"params": {
"speed": 1.0
}
}
}
],
"parameters": {
"speed": {
"type": "range",
"min": 0.1,
"max": 2.0,
"step": 0.1,
"default": 1.0
},
"brightness": {
"type": "range",
"min": 0.1,
"max": 1.0,
"step": 0.1,
"default": 1.0
}
}
}

View File

@@ -0,0 +1,50 @@
{
"name": "Moving Triangle",
"description": "A triangle moving back and forth with gradient colors",
"layers": [
{
"type": "shape",
"shape": "triangle",
"position": {
"x": 8,
"y": 8
},
"size": {
"radius": 4
},
"color": {
"type": "gradient",
"color1": "ff00ff",
"color2": "00ffff"
},
"intensity": 1.0,
"animation": {
"type": "move",
"params": {
"startX": 3,
"startY": 8,
"endX": 13,
"endY": 8,
"duration": 2.0
}
}
}
],
"parameters": {
"speed": {
"type": "range",
"min": 0.1,
"max": 2.0,
"step": 0.1,
"default": 1.0
},
"brightness": {
"type": "range",
"min": 0.1,
"max": 1.0,
"step": 0.1,
"default": 1.0
}
}
}

View File

@@ -0,0 +1,46 @@
{
"name": "Pulsing Circle",
"description": "A pulsing circle in the center with rainbow colors",
"layers": [
{
"type": "shape",
"shape": "circle",
"position": {
"x": 8,
"y": 8
},
"size": {
"radius": 4
},
"color": {
"type": "rainbow"
},
"intensity": 1.0,
"animation": {
"type": "pulse",
"params": {
"minScale": 0.5,
"maxScale": 1.5,
"frequency": 0.8
}
}
}
],
"parameters": {
"speed": {
"type": "range",
"min": 0.1,
"max": 2.0,
"step": 0.1,
"default": 1.0
},
"brightness": {
"type": "range",
"min": 0.1,
"max": 1.0,
"step": 0.1,
"default": 1.0
}
}
}

View File

@@ -0,0 +1,66 @@
{
"name": "Spiral Rainbow",
"description": "A rotating spiral pattern with rainbow gradient",
"layers": [
{
"type": "pattern",
"pattern": "spiral",
"color": {
"type": "palette",
"stops": [
{
"position": 0.0,
"color": "ff0000"
},
{
"position": 0.17,
"color": "ff8800"
},
{
"position": 0.33,
"color": "ffff00"
},
{
"position": 0.5,
"color": "00ff00"
},
{
"position": 0.67,
"color": "0088ff"
},
{
"position": 0.83,
"color": "8800ff"
},
{
"position": 1.0,
"color": "ff0088"
}
]
},
"params": {
"centerX": 8,
"centerY": 8,
"arms": 5,
"rotationSpeed": 1.0
}
}
],
"parameters": {
"speed": {
"type": "range",
"min": 0.1,
"max": 3.0,
"step": 0.1,
"default": 1.0
},
"brightness": {
"type": "range",
"min": 0.1,
"max": 1.0,
"step": 0.1,
"default": 1.0
}
}
}

View File

@@ -12,6 +12,7 @@
<div class="main-navigation">
<div class="nav-left">
<button class="nav-tab active" data-view="stream">📺 Stream</button>
<button class="nav-tab" data-view="editor">🎨 Editor</button>
<button class="nav-tab" data-view="settings">⚙️ Settings</button>
</div>
<div class="nav-right">
@@ -83,6 +84,117 @@
</main>
</div>
<!-- Editor View -->
<div id="editor-view" class="view-content">
<div class="editor-container-modern">
<div class="editor-layout-modern">
<!-- Left Panel: Preset Info & Layers -->
<div class="editor-left-panel">
<!-- Preset -->
<div class="editor-section-compact editor-preset-section">
<div class="preset-header">
<h3 class="section-title-compact">Preset</h3>
<div class="preset-header-buttons">
<button class="btn btn-icon" id="editor-new-preset" title="New Preset">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10,9 9,9 8,9"/>
</svg>
</button>
<button class="btn btn-icon" id="editor-save-preset" title="Save Preset">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17,21 17,13 7,13 7,21"/>
<polyline points="7,3 7,8 15,8"/>
</svg>
</button>
<button class="btn btn-icon" id="editor-delete-preset" title="Delete Preset">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
<button class="btn btn-icon" id="editor-export-json" title="Export JSON">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7,10 12,15 17,10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<label class="btn btn-icon" title="Import JSON">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17,8 12,3 7,8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<input type="file" id="editor-import-json" accept=".json" style="display: none;" />
</label>
</div>
</div>
<!-- Preset Name -->
<div class="editor-input-wrapper">
<label>Name</label>
<input type="text" id="editor-preset-name" value="New Custom Preset" />
</div>
<!-- Preset Actions -->
<div class="preset-actions">
<div class="preset-actions-row">
<select class="preset-select" id="editor-load-preset">
<option value="">Load saved preset...</option>
</select>
</div>
</div>
</div>
<!-- Layers List -->
<div class="editor-section-compact editor-layers-section">
<div class="section-title-with-button">
<h3 class="section-title-compact">Layers</h3>
<button class="btn btn-primary btn-small" id="editor-add-layer"></button>
</div>
<div id="editor-layer-list" class="editor-layer-list-expandable">
<p class="editor-empty-state">No layers yet. Click "Add Layer" to get started!</p>
</div>
</div>
</div>
<!-- Right Panel: Preview & Controls -->
<div class="editor-right-panel">
<!-- Preview Controls Bar -->
<div class="editor-preview-bar">
<div class="preview-bar-controls">
<select id="editor-node-select" class="node-select-compact">
<option value="">Canvas Only</option>
</select>
<button class="btn-preview" id="editor-preview-toggle">
<span class="preview-icon">▶️</span>
<span class="preview-text">Start</span>
</button>
<span class="preview-status-compact" id="editor-preview-status">Ready</span>
</div>
<div class="preview-bar-info">
<span id="editor-canvas-size" class="info-badge">16×16</span>
<span id="editor-canvas-fps" class="info-badge">0 fps</span>
</div>
</div>
<!-- Canvas Preview -->
<div class="editor-preview-main">
<canvas id="editor-preview-canvas" class="editor-canvas-modern"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Settings View -->
<div id="settings-view" class="view-content">
<div class="settings-section">
@@ -130,6 +242,8 @@
<script src="scripts/matrix-display.js"></script>
<script src="scripts/node-canvas-grid.js"></script>
<script src="scripts/preset-controls.js"></script>
<script src="scripts/canvas-renderer.js"></script>
<script src="scripts/preset-editor.js"></script>
<script src="scripts/ledlab-app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,541 @@
// Client-side Canvas Renderer for Preset Editor
// Simplified implementation of building blocks for browser canvas rendering
class CanvasRenderer {
constructor(canvas, width = 16, height = 16) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d', { alpha: false });
this.width = width;
this.height = height;
// Disable image smoothing for pixel-perfect rendering
this.ctx.imageSmoothingEnabled = false;
// Set canvas resolution
this.canvas.width = width;
this.canvas.height = height;
this.frame = [];
this.time = 0;
this.animationStates = new Map();
this.lastFrameTime = Date.now();
}
setSize(width, height) {
this.width = width;
this.height = height;
this.canvas.width = width;
this.canvas.height = height;
this.frame = [];
// Re-apply image smoothing setting after canvas resize
this.ctx.imageSmoothingEnabled = false;
}
createFrame(fill = '000000') {
return new Array(this.width * this.height).fill(fill);
}
hexToRgb(hex) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
}
rgbToHex(rgb) {
const r = Math.max(0, Math.min(255, Math.round(rgb.r))).toString(16).padStart(2, '0');
const g = Math.max(0, Math.min(255, Math.round(rgb.g))).toString(16).padStart(2, '0');
const b = Math.max(0, Math.min(255, Math.round(rgb.b))).toString(16).padStart(2, '0');
return r + g + b;
}
lerpRgb(rgb1, rgb2, t) {
return {
r: rgb1.r + (rgb2.r - rgb1.r) * t,
g: rgb1.g + (rgb2.g - rgb1.g) * t,
b: rgb1.b + (rgb2.b - rgb1.b) * t
};
}
clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
toIndex(col, row) {
// Serpentine wiring
if (row % 2 === 0) {
return row * this.width + col;
}
return row * this.width + (this.width - 1 - col);
}
setPixelColor(frame, col, row, color, intensity = 1.0) {
const index = this.toIndex(col, row);
if (index < 0 || index >= frame.length) return;
if (intensity >= 1.0) {
frame[index] = color;
} else {
const currentRgb = this.hexToRgb(frame[index]);
const newRgb = this.hexToRgb(color);
const blended = {
r: currentRgb.r + (newRgb.r - currentRgb.r) * intensity,
g: currentRgb.g + (newRgb.g - currentRgb.g) * intensity,
b: currentRgb.b + (newRgb.b - currentRgb.b) * intensity
};
frame[index] = this.rgbToHex(blended);
}
}
// Color generators
createColorGenerator(colorConfig) {
if (!colorConfig) {
return () => 'ff0000';
}
const type = colorConfig.type || 'solid';
switch (type) {
case 'solid':
return () => colorConfig.value || 'ff0000';
case 'gradient': {
const rgb1 = this.hexToRgb(colorConfig.color1 || 'ff0000');
const rgb2 = this.hexToRgb(colorConfig.color2 || '0000ff');
return (t) => {
const clamped = this.clamp(t, 0, 1);
return this.rgbToHex(this.lerpRgb(rgb1, rgb2, clamped));
};
}
case 'rainbow': {
return (t) => {
const pos = Math.floor(this.clamp(t, 0, 1) * 255);
const wheelPos = 255 - pos;
let r, g, b;
if (wheelPos < 85) {
r = 255 - wheelPos * 3;
g = 0;
b = wheelPos * 3;
} else if (wheelPos < 170) {
const adjusted = wheelPos - 85;
r = 0;
g = adjusted * 3;
b = 255 - adjusted * 3;
} else {
const adjusted = wheelPos - 170;
r = adjusted * 3;
g = 255 - adjusted * 3;
b = 0;
}
return this.rgbToHex({ r, g, b });
};
}
case 'palette': {
const stops = colorConfig.stops || [
{ position: 0, color: '000000' },
{ position: 1, color: 'ffffff' }
];
return (t) => {
const clamped = this.clamp(t, 0, 1);
// Find the two stops to interpolate between
let stop1 = stops[0];
let stop2 = stops[stops.length - 1];
for (let i = 0; i < stops.length - 1; i++) {
if (clamped >= stops[i].position && clamped <= stops[i + 1].position) {
stop1 = stops[i];
stop2 = stops[i + 1];
break;
}
}
// Interpolate between the two stops
const range = stop2.position - stop1.position;
const localT = range > 0 ? (clamped - stop1.position) / range : 0;
const rgb1 = this.hexToRgb(stop1.color);
const rgb2 = this.hexToRgb(stop2.color);
return this.rgbToHex(this.lerpRgb(rgb1, rgb2, localT));
};
}
default:
return () => 'ff0000';
}
}
// Shape rendering
renderCircle(frame, centerX, centerY, radius, color, intensity = 1.0) {
for (let row = 0; row < this.height; row++) {
for (let col = 0; col < this.width; col++) {
const dx = col - centerX;
const dy = row - centerY;
const distance = Math.hypot(dx, dy);
if (distance <= radius) {
const falloff = 1 - (distance / radius);
const pixelIntensity = falloff * intensity;
this.setPixelColor(frame, col, row, color, pixelIntensity);
}
}
}
}
renderRectangle(frame, x, y, width, height, color, intensity = 1.0) {
for (let row = Math.floor(y); row < Math.min(this.height, y + height); row++) {
for (let col = Math.floor(x); col < Math.min(this.width, x + width); col++) {
if (row >= 0 && col >= 0) {
this.setPixelColor(frame, col, row, color, intensity);
}
}
}
}
renderBlob(frame, centerX, centerY, radius, color, falloffPower = 2) {
for (let row = 0; row < this.height; row++) {
for (let col = 0; col < this.width; col++) {
const dx = col - centerX;
const dy = row - centerY;
const distance = Math.hypot(dx, dy);
const falloff = Math.max(0, 1 - distance / radius);
const intensity = Math.pow(falloff, falloffPower);
if (intensity > 0.01) {
this.setPixelColor(frame, col, row, color, intensity);
}
}
}
}
renderPoint(frame, x, y, color, intensity = 1.0) {
const col = Math.round(x);
const row = Math.round(y);
if (col >= 0 && col < this.width && row >= 0 && row < this.height) {
this.setPixelColor(frame, col, row, color, intensity);
}
}
renderTriangle(frame, x1, y1, x2, y2, x3, y3, color, intensity = 1.0) {
// Simple filled triangle using barycentric coordinates
const minX = Math.max(0, Math.floor(Math.min(x1, x2, x3)));
const maxX = Math.min(this.width - 1, Math.ceil(Math.max(x1, x2, x3)));
const minY = Math.max(0, Math.floor(Math.min(y1, y2, y3)));
const maxY = Math.min(this.height - 1, Math.ceil(Math.max(y1, y2, y3)));
for (let row = minY; row <= maxY; row++) {
for (let col = minX; col <= maxX; col++) {
if (this.isPointInTriangle(col, row, x1, y1, x2, y2, x3, y3)) {
this.setPixelColor(frame, col, row, color, intensity);
}
}
}
}
isPointInTriangle(px, py, x1, y1, x2, y2, x3, y3) {
const d1 = this.sign(px, py, x1, y1, x2, y2);
const d2 = this.sign(px, py, x2, y2, x3, y3);
const d3 = this.sign(px, py, x3, y3, x1, y1);
const hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
const hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
return !(hasNeg && hasPos);
}
sign(px, py, x1, y1, x2, y2) {
return (px - x2) * (y1 - y2) - (x1 - x2) * (py - y2);
}
// Animation generators
createAnimation(animConfig, layerIndex) {
const type = animConfig.type;
const params = animConfig.params || {};
const startTime = Date.now();
switch (type) {
case 'move':
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const duration = params.duration || 2.0;
const t = this.clamp((elapsed % duration) / duration, 0, 1);
return {
x: (params.startX || 0) + ((params.endX || this.width) - (params.startX || 0)) * t,
y: (params.startY || 0) + ((params.endY || this.height) - (params.startY || 0)) * t,
t
};
};
case 'rotate':
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const speed = params.speed || 1.0;
return { angle: elapsed * speed * Math.PI * 2, elapsed };
};
case 'pulse':
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const frequency = params.frequency || 1.0;
const t = (Math.sin(2 * Math.PI * frequency * elapsed) + 1) / 2;
const minScale = params.minScale || 0.5;
const maxScale = params.maxScale || 1.5;
return { scale: minScale + (maxScale - minScale) * t, t, elapsed };
};
case 'oscillateX':
case 'oscillateY':
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const frequency = params.frequency || 0.5;
const center = params.center || (type === 'oscillateX' ? this.width / 2 : this.height / 2);
const amplitude = params.amplitude || 4;
const phase = params.phase || 0;
const value = center + amplitude * Math.sin(2 * Math.PI * frequency * elapsed + phase);
return { value, elapsed };
};
case 'fade':
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const duration = params.duration || 2.0;
const t = this.clamp(elapsed / duration, 0, 1);
const fadeIn = params.fadeIn !== false;
return { intensity: fadeIn ? t : (1 - t), t, elapsed };
};
default:
return () => ({});
}
}
// Render a complete preset configuration
renderConfiguration(configuration, speed = 1.0, brightness = 1.0) {
const now = Date.now();
const deltaTime = (now - this.lastFrameTime) / 1000;
this.lastFrameTime = now;
this.time += deltaTime * speed;
let frame = this.createFrame('000000');
// Render each layer
configuration.layers?.forEach((layer, index) => {
this.renderLayer(frame, layer, index, brightness);
});
return frame;
}
renderLayer(frame, layer, layerIndex, globalBrightness) {
const type = layer.type;
if (type === 'shape') {
this.renderShapeLayer(frame, layer, layerIndex, globalBrightness);
} else if (type === 'pattern') {
this.renderPatternLayer(frame, layer, layerIndex, globalBrightness);
}
}
renderShapeLayer(frame, layer, layerIndex, globalBrightness) {
let position = layer.position || { x: this.width / 2, y: this.height / 2 };
let size = layer.size || { radius: 3 };
let rotation = 0;
let scale = 1.0;
let intensity = (layer.intensity || 1.0) * globalBrightness;
// Initialize animation if needed
if (layer.animation && !this.animationStates.has(layerIndex)) {
this.animationStates.set(layerIndex, this.createAnimation(layer.animation, layerIndex));
}
// Apply animation
if (this.animationStates.has(layerIndex)) {
const animation = this.animationStates.get(layerIndex);
const animState = animation();
if (animState.x !== undefined && animState.y !== undefined) {
position = { x: animState.x, y: animState.y };
}
if (animState.angle !== undefined) {
rotation = animState.angle;
}
if (animState.scale !== undefined) {
scale = animState.scale;
}
if (animState.intensity !== undefined) {
intensity *= animState.intensity;
}
if (animState.value !== undefined && layer.animation?.axis === 'x') {
position.x = animState.value;
} else if (animState.value !== undefined && layer.animation?.axis === 'y') {
position.y = animState.value;
}
}
// Create color generator
const colorGen = this.createColorGenerator(layer.color);
const color = typeof colorGen === 'function' ? colorGen(0.5) : colorGen;
// Apply scale to size
const scaledSize = {
radius: (size.radius || 3) * scale,
width: (size.width || 5) * scale,
height: (size.height || 5) * scale
};
// Render shape
const shapeType = layer.shape || 'circle';
switch (shapeType) {
case 'circle':
this.renderCircle(frame, position.x, position.y, scaledSize.radius, color, intensity);
break;
case 'rectangle':
this.renderRectangle(
frame,
position.x - scaledSize.width / 2,
position.y - scaledSize.height / 2,
scaledSize.width,
scaledSize.height,
color,
intensity
);
break;
case 'triangle': {
// Triangle with rotation support
const triSize = scaledSize.radius || 3;
const points = [
{ x: 0, y: -triSize },
{ x: -triSize, y: triSize },
{ x: triSize, y: triSize }
];
// Apply rotation if there's an animation with angle
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const rotated = points.map(p => ({
x: p.x * cos - p.y * sin,
y: p.x * sin + p.y * cos
}));
this.renderTriangle(
frame,
position.x + rotated[0].x,
position.y + rotated[0].y,
position.x + rotated[1].x,
position.y + rotated[1].y,
position.x + rotated[2].x,
position.y + rotated[2].y,
color,
intensity
);
break;
}
case 'blob':
this.renderBlob(frame, position.x, position.y, scaledSize.radius, color, layer.falloffPower || 2);
break;
case 'point':
this.renderPoint(frame, position.x, position.y, color, intensity);
break;
}
}
renderPatternLayer(frame, layer, layerIndex, globalBrightness) {
// Simplified pattern rendering for canvas
const patternType = layer.pattern;
const intensity = (layer.intensity || 1.0) * globalBrightness;
if (patternType === 'radial' || patternType === 'spiral') {
const params = layer.params || {};
const centerX = params.centerX || this.width / 2;
const centerY = params.centerY || this.height / 2;
const maxRadius = Math.hypot(this.width / 2, this.height / 2);
const colorGen = this.createColorGenerator(layer.color);
for (let row = 0; row < this.height; row++) {
for (let col = 0; col < this.width; col++) {
const dx = col - centerX;
const dy = row - centerY;
const distance = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx);
let t;
if (patternType === 'spiral') {
const arms = params.arms || 5;
const rotationSpeed = params.rotationSpeed || 1.0;
const spiralValue = (Math.sin(arms * (angle + this.time * rotationSpeed)) + 1) / 2;
const radiusValue = distance / maxRadius;
t = this.clamp(spiralValue * 0.5 + radiusValue * 0.5, 0, 1);
} else {
t = this.clamp(distance / maxRadius, 0, 1);
}
const color = colorGen(t);
// Use setPixelColor for proper layer compositing
this.setPixelColor(frame, col, row, color, intensity);
}
}
}
}
// Draw frame to canvas
drawFrame(frame) {
// Clear canvas first
this.ctx.fillStyle = '#000000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
const pixelWidth = this.canvas.width / this.width;
const pixelHeight = this.canvas.height / this.height;
for (let row = 0; row < this.height; row++) {
for (let col = 0; col < this.width; col++) {
const index = this.toIndex(col, row);
const color = frame[index];
if (color) {
const rgb = this.hexToRgb(color);
this.ctx.fillStyle = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
this.ctx.fillRect(col * pixelWidth, row * pixelHeight, pixelWidth, pixelHeight);
}
}
}
}
clear() {
this.ctx.fillStyle = '#000000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
reset() {
this.animationStates.clear();
this.time = 0;
this.lastFrameTime = Date.now();
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = CanvasRenderer;
}

View File

@@ -163,6 +163,9 @@ class NodeCanvasGrid extends Component {
return;
}
// Set the data-item-count attribute for proper grid layout
gridContainer.setAttribute('data-item-count', validNodes.length);
validNodes.forEach(node => {
const nodeItem = this.createNodeCanvasItem(node);
gridContainer.appendChild(nodeItem);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

202
server/gateway-client.js Normal file
View File

@@ -0,0 +1,202 @@
// Gateway Client - Communicates with spore-gateway for node discovery
const WebSocket = require('ws');
class GatewayClient {
constructor(options = {}) {
this.gatewayUrl = options.gatewayUrl || 'http://localhost:3001';
this.filterAppLabel = options.filterAppLabel || 'pixelstream'; // Filter nodes by app label, set to null to disable
this.nodes = new Map(); // ip -> { lastSeen, status, hostname, port }
this.isRunning = false;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 2000;
this.reconnectTimer = null;
}
start() {
if (this.isRunning) {
return;
}
this.isRunning = true;
console.log(`Starting Gateway client, connecting to ${this.gatewayUrl}`);
this.connectWebSocket();
}
stop() {
if (!this.isRunning) {
return;
}
this.isRunning = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.nodes.clear();
console.log('Gateway client stopped');
}
connectWebSocket() {
try {
// Convert http:// to ws:// for WebSocket
const wsUrl = this.gatewayUrl.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws';
console.log(`Connecting to WebSocket: ${wsUrl}`);
this.ws = new WebSocket(wsUrl);
this.ws.on('open', () => {
console.log('WebSocket connected to gateway');
this.reconnectAttempts = 0;
});
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleWebSocketMessage(message);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
});
this.ws.on('close', (code, reason) => {
console.log(`WebSocket connection closed: ${code} ${reason}`);
if (this.isRunning) {
this.scheduleReconnect();
}
});
this.ws.on('error', (error) => {
console.error('WebSocket error:', error.message);
});
} catch (error) {
console.error('Failed to create WebSocket connection:', error);
this.scheduleReconnect();
}
}
handleWebSocketMessage(message) {
if (message.topic === 'cluster/update') {
this.handleClusterUpdate(message.members, message.primaryNode, message.totalNodes);
} else if (message.topic === 'node/discovery') {
this.handleNodeDiscovery(message.action, message.nodeIp);
}
}
handleClusterUpdate(members, primaryNode, totalNodes) {
const newNodes = new Map();
if (members && Array.isArray(members)) {
members.forEach(node => {
// Filter for nodes with specified app label (if filtering is enabled)
if (this.filterAppLabel && !this.hasAppLabel(node, this.filterAppLabel)) {
return;
}
const nodeIp = node.ip || node.IP;
newNodes.set(nodeIp, {
lastSeen: Date.now(),
status: node.status || node.Status || 'active',
hostname: node.hostname || node.Hostname || nodeIp,
port: node.port || node.Port || 4210,
isPrimary: (primaryNode === nodeIp),
labels: node.labels || node.Labels
});
});
}
// Check for newly discovered nodes
for (const [ip, nodeInfo] of newNodes.entries()) {
const existingNode = this.nodes.get(ip);
if (!existingNode) {
console.log(`Node discovered via gateway: ${ip} (${nodeInfo.hostname})`);
this.nodes.set(ip, nodeInfo);
} else if (existingNode.hostname !== nodeInfo.hostname) {
console.log(`Node hostname updated: ${ip} -> ${nodeInfo.hostname}`);
this.nodes.set(ip, nodeInfo);
} else {
// Update status and last seen
existingNode.status = nodeInfo.status;
existingNode.lastSeen = nodeInfo.lastSeen;
existingNode.isPrimary = nodeInfo.isPrimary;
}
}
// Check for lost nodes
for (const ip of this.nodes.keys()) {
if (!newNodes.has(ip)) {
console.log(`Node lost via gateway: ${ip}`);
this.nodes.delete(ip);
}
}
}
handleNodeDiscovery(action, nodeIp) {
if (action === 'discovered') {
// Node was discovered - actual data will come via cluster/update
console.log(`Node discovered event: ${nodeIp}`);
} else if (action === 'removed') {
console.log(`Node removed event: ${nodeIp}`);
this.nodes.delete(nodeIp);
}
}
scheduleReconnect() {
if (!this.isRunning) {
return;
}
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max WebSocket reconnection attempts reached');
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`Attempting to reconnect WebSocket in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.reconnectTimer = setTimeout(() => {
this.connectWebSocket();
}, delay);
}
getNodes() {
return Array.from(this.nodes.entries()).map(([ip, node]) => ({
ip,
hostname: node.hostname || ip,
port: node.port,
status: node.status,
isPrimary: node.isPrimary,
...node
}));
}
getNodeCount() {
return this.nodes.size;
}
hasAppLabel(node, appLabel) {
// Check if node has the app: <appLabel> label
if (!node.labels || typeof node.labels !== 'object') {
return false;
}
return node.labels.app === appLabel;
}
}
module.exports = GatewayClient;

View File

@@ -6,13 +6,16 @@ const WebSocket = require('ws');
const path = require('path');
// Import services
const UdpDiscovery = require('./udp-discovery');
const UdpSender = require('./udp-discovery');
const GatewayClient = require('./gateway-client');
const PresetRegistry = require('../presets/preset-registry');
class LEDLabServer {
constructor(options = {}) {
this.port = options.port || 8080;
this.udpPort = options.udpPort || 4210;
this.gatewayUrl = options.gatewayUrl || 'http://localhost:3001';
this.filterAppLabel = options.filterAppLabel || 'pixelstream';
this.matrixWidth = options.matrixWidth || 16;
this.matrixHeight = options.matrixHeight || 16;
this.fps = options.fps || 20;
@@ -21,7 +24,11 @@ class LEDLabServer {
this.server = http.createServer(this.app);
this.wss = new WebSocket.Server({ server: this.server });
this.udpDiscovery = new UdpDiscovery(this.udpPort);
this.udpSender = new UdpSender(this.udpPort);
this.gatewayClient = new GatewayClient({
gatewayUrl: this.gatewayUrl,
filterAppLabel: this.filterAppLabel || 'pixelstream'
});
this.presetRegistry = new PresetRegistry();
// Legacy single-stream support (kept for backwards compatibility)
@@ -37,7 +44,7 @@ class LEDLabServer {
this.setupExpress();
this.setupWebSocket();
this.setupUdpDiscovery();
this.setupGatewayClient();
this.setupPresetManager();
}
@@ -47,7 +54,7 @@ class LEDLabServer {
// API routes
this.app.get('/api/nodes', (req, res) => {
const nodes = this.udpDiscovery.getNodes();
const nodes = this.gatewayClient.getNodes();
res.json({ nodes });
});
@@ -61,7 +68,7 @@ class LEDLabServer {
streaming: this.currentPreset !== null,
currentPreset: this.currentPresetName || null,
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
nodeCount: this.udpDiscovery.getNodeCount(),
nodeCount: this.gatewayClient.getNodeCount(),
currentTarget: this.currentTarget,
fps: this.fps,
});
@@ -122,7 +129,7 @@ class LEDLabServer {
streaming: this.currentPreset !== null,
currentPreset: this.currentPresetName || null,
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
nodes: this.udpDiscovery.getNodes(),
nodes: this.gatewayClient.getNodes(),
presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null,
currentTarget: this.currentTarget,
fps: this.fps,
@@ -141,6 +148,10 @@ class LEDLabServer {
this.startPreset(data.presetName, data.width, data.height, data.nodeIp, data.parameters);
break;
case 'startCustomPreset':
this.startCustomPreset(data.configuration, data.nodeIp);
break;
case 'stopStreaming':
this.stopStreaming(data.nodeIp);
break;
@@ -170,26 +181,14 @@ class LEDLabServer {
}
}
setupUdpDiscovery() {
this.udpDiscovery.on('nodeDiscovered', (node) => {
console.log('Node discovered:', node.ip);
setupGatewayClient() {
// Start gateway client for node discovery
this.gatewayClient.start();
this.broadcastToClients({
type: 'nodeDiscovered',
node
});
});
// Start UDP sender for sending frames
this.udpSender.start();
this.udpDiscovery.on('nodeLost', (node) => {
console.log('Node lost:', node.ip);
this.broadcastToClients({
type: 'nodeLost',
node
});
});
this.udpDiscovery.start();
console.log('Using gateway for node discovery and UDP sender for frame streaming');
}
setupPresetManager() {
@@ -267,6 +266,72 @@ class LEDLabServer {
}
}
startCustomPreset(configuration, nodeIp = null) {
try {
const CustomPreset = require('../presets/custom-preset');
const targetIp = nodeIp || this.currentTarget;
if (!targetIp) {
console.warn('No target specified for streaming');
return;
}
// Extract dimensions from configuration or use defaults
const width = configuration.width || this.matrixWidth;
const height = configuration.height || this.matrixHeight;
// Stop current streaming for this node if active
this.stopStreaming(targetIp);
// Create custom preset instance with configuration
const preset = new CustomPreset(width, height, configuration);
preset.start();
console.log(`Started custom preset: ${configuration.name} (${width}x${height}) for ${targetIp}`);
// Start streaming interval for this node
const intervalMs = Math.floor(1000 / this.fps);
const interval = setInterval(() => {
this.streamFrameForNode(targetIp);
}, intervalMs);
// Store stream information
this.nodeStreams.set(targetIp, {
preset,
presetName: 'custom-' + configuration.name,
interval,
matrixSize: { width, height },
parameters: preset.getParameters(),
configuration: configuration
});
// Update legacy support
if (targetIp === this.currentTarget) {
this.currentPreset = preset;
this.currentPresetName = 'custom-' + configuration.name;
this.streamingInterval = interval;
}
// Notify clients
this.broadcastToClients({
type: 'streamingStarted',
preset: preset.getMetadata(),
nodeIp: targetIp,
isCustom: true
});
// Also send updated state to keep all clients in sync
this.broadcastCurrentState();
} catch (error) {
console.error('Error starting custom preset:', error);
this.broadcastToClients({
type: 'error',
message: `Failed to start custom preset: ${error.message}`
});
}
}
stopStreaming(nodeIp = null) {
const targetIp = nodeIp || this.currentTarget;
@@ -371,7 +436,9 @@ class LEDLabServer {
if (frameData) {
// Send to specific target
if (this.currentTarget) {
this.udpDiscovery.sendToNode(this.currentTarget, frameData);
this.udpSender.sendToNode(this.currentTarget, frameData).catch(err => {
console.error(`Error sending frame to ${this.currentTarget}:`, err);
});
}
// Send frame data to WebSocket clients for preview
@@ -392,7 +459,10 @@ class LEDLabServer {
const frameData = stream.preset.generateFrame();
if (frameData) {
// Send to specific node
this.udpDiscovery.sendToNode(nodeIp, frameData);
// frameData format: "RAW:FF0000FF0000..." (RAW prefix + hex pixel data)
this.udpSender.sendToNode(nodeIp, frameData).catch(err => {
console.error(`Error sending frame to ${nodeIp}:`, err);
});
// Send frame data to WebSocket clients for preview
this.broadcastToClients({
@@ -405,7 +475,7 @@ class LEDLabServer {
}
sendToSpecificNode(nodeIp, message) {
return this.udpDiscovery.sendToNode(nodeIp, message);
return this.udpSender.sendToNode(nodeIp, message);
}
broadcastCurrentState() {
@@ -413,7 +483,7 @@ class LEDLabServer {
streaming: this.currentPreset !== null,
currentPreset: this.currentPresetName || null,
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
nodes: this.udpDiscovery.getNodes(),
nodes: this.gatewayClient.getNodes(),
presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null,
currentTarget: this.currentTarget,
fps: this.fps,
@@ -533,7 +603,8 @@ class LEDLabServer {
startServer() {
this.server.listen(this.port, () => {
console.log(`LEDLab server running on port ${this.port}`);
console.log(`UDP discovery on port ${this.udpPort}`);
console.log(`Gateway client connecting to ${this.gatewayUrl}`);
console.log(`UDP sender configured for port ${this.udpPort}`);
console.log(`Matrix size: ${this.matrixWidth}x${this.matrixHeight}`);
});
}
@@ -544,8 +615,9 @@ class LEDLabServer {
// Stop streaming first
this.stopStreaming();
// Stop UDP discovery
this.udpDiscovery.stop();
// Stop gateway client and UDP sender
this.gatewayClient.stop();
this.udpSender.stop();
// Close all WebSocket connections immediately
this.wss.close();
@@ -575,6 +647,8 @@ if (require.main === module) {
const server = new LEDLabServer({
port: process.env.PORT || 8080,
udpPort: process.env.UDP_PORT || 4210,
gatewayUrl: process.env.GATEWAY_URL || 'http://localhost:3001',
filterAppLabel: process.env.FILTER_APP_LABEL || 'pixelstream',
matrixWidth: parseInt(process.env.MATRIX_WIDTH) || 16,
matrixHeight: parseInt(process.env.MATRIX_HEIGHT) || 16,
});

View File

@@ -1,35 +1,12 @@
// UDP Discovery service for SPORE nodes
// UDP Sender service for sending frames to SPORE nodes
const dgram = require('dgram');
const EventEmitter = require('events');
const os = require('os');
class UdpDiscovery extends EventEmitter {
class UdpSender {
constructor(port = 4210) {
super();
this.port = port;
this.socket = null;
this.nodes = new Map(); // ip -> { lastSeen, status }
this.discoveryInterval = null;
this.isRunning = false;
// Get local network interfaces to filter out local server
this.localInterfaces = this.getLocalInterfaces();
}
getLocalInterfaces() {
const interfaces = os.networkInterfaces();
const localIPs = new Set();
Object.values(interfaces).forEach(iface => {
iface.forEach(addr => {
if (addr.family === 'IPv4' && !addr.internal) {
localIPs.add(addr.address);
}
});
});
return localIPs;
}
start() {
@@ -40,24 +17,15 @@ class UdpDiscovery extends EventEmitter {
this.socket = dgram.createSocket('udp4');
this.isRunning = true;
this.socket.on('message', (msg, rinfo) => {
this.handleMessage(msg, rinfo);
});
this.socket.on('error', (err) => {
console.error('UDP Discovery socket error:', err);
this.emit('error', err);
console.error('UDP Sender socket error:', err);
});
this.socket.bind(this.port, () => {
console.log(`UDP Discovery listening on port ${this.port}`);
// Enable broadcast after binding
this.socket.bind(0, () => {
// Bind to any available port
this.socket.setBroadcast(true);
this.emit('started');
console.log(`UDP Sender ready on port ${this.socket.address().port}`);
});
// Start periodic discovery broadcast
this.startDiscoveryBroadcast();
}
stop() {
@@ -67,101 +35,12 @@ class UdpDiscovery extends EventEmitter {
this.isRunning = false;
if (this.discoveryInterval) {
clearInterval(this.discoveryInterval);
this.discoveryInterval = null;
}
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.nodes.clear();
console.log('UDP Discovery stopped');
this.emit('stopped');
}
handleMessage(msg, rinfo) {
const message = msg.toString('utf8');
const nodeIp = rinfo.address;
// Skip local server IPs
if (this.localInterfaces.has(nodeIp)) {
return;
}
// Update node last seen time
this.nodes.set(nodeIp, {
lastSeen: Date.now(),
status: 'connected',
address: nodeIp,
port: rinfo.port
});
// Emit node discovered/updated event
this.emit('nodeDiscovered', {
ip: nodeIp,
port: rinfo.port,
status: 'connected'
});
// Clean up stale nodes periodically
this.cleanupStaleNodes();
}
startDiscoveryBroadcast() {
// Broadcast discovery message every 5 seconds
this.discoveryInterval = setInterval(() => {
this.broadcastDiscovery();
}, 5000);
// Send initial broadcast
this.broadcastDiscovery();
}
broadcastDiscovery() {
if (!this.socket) {
return;
}
const discoveryMessage = 'SPORE_DISCOVERY';
const message = Buffer.from(discoveryMessage, 'utf8');
// Broadcast to all nodes on the network (broadcast already enabled in bind callback)
this.socket.send(message, 0, message.length, this.port, '255.255.255.255', (err) => {
if (err) {
console.error('Error broadcasting discovery message:', err);
} else {
console.log('Discovery message broadcasted');
}
});
}
cleanupStaleNodes() {
const now = Date.now();
const staleThreshold = 10000; // 10 seconds
for (const [ip, node] of this.nodes.entries()) {
if (now - node.lastSeen > staleThreshold) {
this.nodes.delete(ip);
this.emit('nodeLost', { ip, status: 'disconnected' });
}
}
}
getNodes() {
const nodes = Array.from(this.nodes.entries()).map(([ip, node]) => ({
ip,
...node
}));
return nodes;
}
getNodeCount() {
return this.nodes.size;
console.log('UDP Sender stopped');
}
sendToNode(nodeIp, message) {
@@ -170,15 +49,17 @@ class UdpDiscovery extends EventEmitter {
}
const buffer = Buffer.from(message, 'utf8');
return new Promise((resolve, reject) => {
this.socket.send(buffer, 0, buffer.length, this.port, nodeIp, (err) => {
if (err) {
console.error(`Error sending to node ${nodeIp}:`, err);
return false;
reject(err);
return;
}
return true;
resolve(true);
});
});
return true;
}
broadcastToAll(message) {
@@ -189,16 +70,17 @@ class UdpDiscovery extends EventEmitter {
const buffer = Buffer.from(message, 'utf8');
this.socket.setBroadcast(true);
return new Promise((resolve, reject) => {
this.socket.send(buffer, 0, buffer.length, this.port, '255.255.255.255', (err) => {
if (err) {
console.error('Error broadcasting message:', err);
return false;
reject(err);
return;
}
return true;
resolve(true);
});
});
return true;
}
}
module.exports = UdpDiscovery;
module.exports = UdpSender;