From f3d99b174f99310604f1c0a16595e2f1475303d5 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Wed, 1 Oct 2025 21:47:27 +0200 Subject: [PATCH 1/5] feat: udp stream --- .../pixelstream/PixelStreamController.cpp | 127 ++++++++++++++++++ examples/pixelstream/PixelStreamController.h | 42 ++++++ examples/pixelstream/README.md | 33 +++++ examples/pixelstream/main.cpp | 60 +++++++++ include/spore/core/ClusterManager.h | 2 + include/spore/internal/Globals.h | 1 + platformio.ini | 21 +++ src/spore/core/ApiServer.cpp | 5 + src/spore/core/ClusterManager.cpp | 27 ++++ test/package.json | 5 + test/pixelstream/bouncing-ball.js | 80 +++++++++++ test/pixelstream/fade-green-blue.js | 55 ++++++++ test/pixelstream/rainbow.js | 59 ++++++++ 13 files changed, 517 insertions(+) create mode 100644 examples/pixelstream/PixelStreamController.cpp create mode 100644 examples/pixelstream/PixelStreamController.h create mode 100644 examples/pixelstream/README.md create mode 100644 examples/pixelstream/main.cpp create mode 100644 test/pixelstream/bouncing-ball.js create mode 100644 test/pixelstream/fade-green-blue.js create mode 100644 test/pixelstream/rainbow.js diff --git a/examples/pixelstream/PixelStreamController.cpp b/examples/pixelstream/PixelStreamController.cpp new file mode 100644 index 0000000..46fdb7f --- /dev/null +++ b/examples/pixelstream/PixelStreamController.cpp @@ -0,0 +1,127 @@ +#include "PixelStreamController.h" + +namespace { + constexpr int COMPONENTS_PER_PIXEL = 3; +} + +PixelStreamController::PixelStreamController(NodeContext& ctxRef, const PixelStreamConfig& cfg) + : ctx(ctxRef), config(cfg), pixels(cfg.pixelCount, cfg.pin, cfg.pixelType) { +} + +void PixelStreamController::begin() { + pixels.begin(); + pixels.setBrightness(config.brightness); + // Default all pixels to green so we can verify hardware before streaming frames + for (uint16_t i = 0; i < config.pixelCount; ++i) { + pixels.setPixelColor(i, pixels.Color(0, 255, 0)); + } + pixels.show(); + + ctx.on("udp/raw", [this](void* data) { + this->handleEvent(data); + }); + + LOG_INFO("PixelStream", String("PixelStreamController ready on pin ") + String(config.pin) + " with " + String(config.pixelCount) + " pixels"); +} + +void PixelStreamController::handleEvent(void* data) { + if (data == nullptr) { + return; + } + + String* payload = static_cast(data); + if (!payload) { + return; + } + + if (!applyFrame(*payload)) { + LOG_WARN("PixelStream", String("Ignoring RAW payload with invalid length (") + String(payload->length()) + ")"); + } +} + +bool PixelStreamController::applyFrame(const String& payload) { + static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2; + const std::size_t payloadLength = static_cast(payload.length()); + + if (payloadLength == 0 || (payloadLength % frameWidth) != 0) { + LOG_WARN("PixelStream", String("Payload size ") + String(payloadLength) + " is not a multiple of " + String(frameWidth)); + return false; + } + + const uint16_t framesProvided = static_cast(payloadLength / frameWidth); + const uint16_t pixelsToUpdate = std::min(config.pixelCount, framesProvided); + + for (uint16_t index = 0; index < pixelsToUpdate; ++index) { + const std::size_t base = static_cast(index) * frameWidth; + FrameComponents components{}; + if (!tryParsePixel(payload, base, components)) { + LOG_WARN("PixelStream", String("Invalid hex data at pixel index ") + String(index)); + return false; + } + const uint16_t hardwareIndex = mapPixelIndex(index); + pixels.setPixelColor(hardwareIndex, pixels.Color(components.red, components.green, components.blue)); + } + + // Clear any remaining pixels so stale data is removed when fewer frames are provided + for (uint16_t index = pixelsToUpdate; index < config.pixelCount; ++index) { + const uint16_t hardwareIndex = mapPixelIndex(index); + pixels.setPixelColor(hardwareIndex, 0); + } + + pixels.show(); + return true; +} + +uint16_t PixelStreamController::mapPixelIndex(uint16_t logicalIndex) const { + if (config.matrixWidth == 0) { + return logicalIndex; + } + + const uint16_t row = logicalIndex / config.matrixWidth; + const uint16_t col = logicalIndex % config.matrixWidth; + + if (!config.matrixSerpentine || (row % 2 == 0)) { + return row * config.matrixWidth + col; + } + + const uint16_t reversedCol = (config.matrixWidth - 1) - col; + return row * config.matrixWidth + reversedCol; +} + +int PixelStreamController::hexToNibble(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return 10 + c - 'a'; + } + if (c >= 'A' && c <= 'F') { + return 10 + c - 'A'; + } + return -1; +} + +bool PixelStreamController::tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const { + static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2; + if (startIndex + frameWidth > static_cast(payload.length())) { + return false; + } + + const int rHi = hexToNibble(payload[startIndex]); + const int rLo = hexToNibble(payload[startIndex + 1]); + const int gHi = hexToNibble(payload[startIndex + 2]); + const int gLo = hexToNibble(payload[startIndex + 3]); + const int bHi = hexToNibble(payload[startIndex + 4]); + const int bLo = hexToNibble(payload[startIndex + 5]); + + if (rHi < 0 || rLo < 0 || gHi < 0 || gLo < 0 || bHi < 0 || bLo < 0) { + return false; + } + + components.red = static_cast((rHi << 4) | rLo); + components.green = static_cast((gHi << 4) | gLo); + components.blue = static_cast((bHi << 4) | bLo); + return true; +} + + diff --git a/examples/pixelstream/PixelStreamController.h b/examples/pixelstream/PixelStreamController.h new file mode 100644 index 0000000..cf1fb5c --- /dev/null +++ b/examples/pixelstream/PixelStreamController.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include +#include "spore/core/NodeContext.h" +#include "spore/util/Logging.h" + +struct PixelStreamConfig { + uint8_t pin; + uint16_t pixelCount; + uint8_t brightness; + uint16_t matrixWidth; + bool matrixSerpentine; + neoPixelType pixelType; +}; + +class PixelStreamController { +public: + PixelStreamController(NodeContext& ctx, const PixelStreamConfig& config); + void begin(); + +private: + struct FrameComponents { + uint8_t red; + uint8_t green; + uint8_t blue; + }; + + bool tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const; + void handleEvent(void* data); + bool applyFrame(const String& payload); + uint16_t mapPixelIndex(uint16_t logicalIndex) const; + static int hexToNibble(char c); + + NodeContext& ctx; + PixelStreamConfig config; + Adafruit_NeoPixel pixels; +}; + + diff --git a/examples/pixelstream/README.md b/examples/pixelstream/README.md new file mode 100644 index 0000000..6a482c4 --- /dev/null +++ b/examples/pixelstream/README.md @@ -0,0 +1,33 @@ +# PixelStream Example + +This example demonstrates how to consume the `udp/raw` cluster event and drive a NeoPixel strip or matrix directly from streamed RGB data. Frames are provided as hex encoded byte triplets (`RRGGBB` per pixel). + +## Features + +- Subscribes to `udp/raw` via `NodeContext::on`. +- Converts incoming frames into pixel colors for strips or matrices. +- Supports serpentine (zig-zag) matrix wiring. + +## Payload Format + +Each packet is expected to be `RAW:` followed by `pixelCount * 3 * 2` hexadecimal characters. For example, for 8 pixels: + +``` +RAW:FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000 +``` + +## Usage + +### Strip Mode + +Upload the example with `PIXEL_MATRIX_WIDTH` set to 0 (default). Send frames containing `PIXEL_COUNT * 3` bytes as hex. + +### Matrix Mode + +Set `PIXEL_MATRIX_WIDTH` to the number of columns. The controller remaps even/odd rows to support serpentine wiring. + +## Configuration + +Adjust `PIXEL_PIN`, `PIXEL_COUNT`, `PIXEL_BRIGHTNESS`, `PIXEL_MATRIX_WIDTH`, `PIXEL_MATRIX_SERPENTINE`, and `PIXEL_TYPE` through build defines or editing `main.cpp`. + + diff --git a/examples/pixelstream/main.cpp b/examples/pixelstream/main.cpp new file mode 100644 index 0000000..1685d2e --- /dev/null +++ b/examples/pixelstream/main.cpp @@ -0,0 +1,60 @@ +#include +#include "spore/Spore.h" +#include "spore/util/Logging.h" +#include "PixelStreamController.h" + +#ifndef PIXEL_PIN +#define PIXEL_PIN 2 +#endif + +#ifndef PIXEL_COUNT +#define PIXEL_COUNT 64 +#endif + +#ifndef PIXEL_BRIGHTNESS +#define PIXEL_BRIGHTNESS 80 +#endif + +#ifndef PIXEL_MATRIX_WIDTH +#define PIXEL_MATRIX_WIDTH 0 +#endif + +#ifndef PIXEL_MATRIX_SERPENTINE +#define PIXEL_MATRIX_SERPENTINE 1 +#endif + +#ifndef PIXEL_TYPE +#define PIXEL_TYPE NEO_GRB + NEO_KHZ800 +#endif + +Spore spore({ + {"app", "pixelstream"}, + {"role", "led"}, + {"pixels", String(PIXEL_COUNT)} +}); + +PixelStreamController* controller = nullptr; + +void setup() { + spore.setup(); + + PixelStreamConfig config{ + static_cast(PIXEL_PIN), + static_cast(PIXEL_COUNT), + static_cast(PIXEL_BRIGHTNESS), + static_cast(PIXEL_MATRIX_WIDTH), + static_cast(PIXEL_MATRIX_SERPENTINE), + static_cast(PIXEL_TYPE) + }; + + controller = new PixelStreamController(spore.getContext(), config); + controller->begin(); + + spore.begin(); +} + +void loop() { + spore.loop(); +} + + diff --git a/include/spore/core/ClusterManager.h b/include/spore/core/ClusterManager.h index ca4095c..c742a92 100644 --- a/include/spore/core/ClusterManager.h +++ b/include/spore/core/ClusterManager.h @@ -40,11 +40,13 @@ private: static bool isResponseMsg(const char* msg); static bool isNodeInfoMsg(const char* msg); static bool isClusterEventMsg(const char* msg); + static bool isRawMsg(const char* msg); void onDiscovery(const char* msg); void onHeartbeat(const char* msg); void onResponse(const char* msg); void onNodeInfo(const char* msg); void onClusterEvent(const char* msg); + void onRawMessage(const char* msg); unsigned long lastHeartbeatSentAt = 0; std::vector messageHandlers; }; diff --git a/include/spore/internal/Globals.h b/include/spore/internal/Globals.h index 690f2e8..ebb0fc8 100644 --- a/include/spore/internal/Globals.h +++ b/include/spore/internal/Globals.h @@ -10,6 +10,7 @@ namespace ClusterProtocol { constexpr const char* HEARTBEAT_MSG = "CLUSTER_HEARTBEAT"; constexpr const char* NODE_INFO_MSG = "CLUSTER_NODE_INFO"; constexpr const char* CLUSTER_EVENT_MSG = "CLUSTER_EVENT"; + constexpr const char* RAW_MSG = "RAW"; constexpr uint16_t UDP_PORT = 4210; // Increased buffer to accommodate node info JSON over UDP constexpr size_t UDP_BUF_SIZE = 512; diff --git a/platformio.ini b/platformio.ini index b8f0dc8..ac9cce8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -100,3 +100,24 @@ build_src_filter = + + + + +[env:pixelstream] +platform = platformio/espressif8266@^4.2.1 +board = esp01_1m +framework = arduino +upload_speed = 115200 +monitor_speed = 115200 +board_build.filesystem = littlefs +board_build.flash_mode = dout +board_build.ldscript = eagle.flash.1m64.ld +lib_deps = ${common.lib_deps} + adafruit/Adafruit NeoPixel@^1.15.1 +build_flags = +build_src_filter = + + + + + + + + + + + + + + diff --git a/src/spore/core/ApiServer.cpp b/src/spore/core/ApiServer.cpp index 767ceb6..ae95a4e 100644 --- a/src/spore/core/ApiServer.cpp +++ b/src/spore/core/ApiServer.cpp @@ -152,6 +152,11 @@ void ApiServer::setupWebSocket() { // Subscribe to all local events and forward to websocket clients ctx.onAny([this](const std::string& event, void* dataPtr) { + // Ignore raw UDP frames + if (event == "udp/raw") { + return; + } + String* payloadStrPtr = static_cast(dataPtr); String payloadStr = payloadStrPtr ? *payloadStrPtr : String(""); diff --git a/src/spore/core/ClusterManager.cpp b/src/spore/core/ClusterManager.cpp index 5d7999b..2e2bd59 100644 --- a/src/spore/core/ClusterManager.cpp +++ b/src/spore/core/ClusterManager.cpp @@ -68,6 +68,7 @@ void ClusterManager::listen() { void ClusterManager::initMessageHandlers() { messageHandlers.clear(); + messageHandlers.push_back({ &ClusterManager::isRawMsg, [this](const char* msg){ this->onRawMessage(msg); }, "RAW" }); messageHandlers.push_back({ &ClusterManager::isDiscoveryMsg, [this](const char* msg){ this->onDiscovery(msg); }, "DISCOVERY" }); messageHandlers.push_back({ &ClusterManager::isHeartbeatMsg, [this](const char* msg){ this->onHeartbeat(msg); }, "HEARTBEAT" }); messageHandlers.push_back({ &ClusterManager::isResponseMsg, [this](const char* msg){ this->onResponse(msg); }, "RESPONSE" }); @@ -113,6 +114,15 @@ bool ClusterManager::isClusterEventMsg(const char* msg) { return strncmp(msg, ClusterProtocol::CLUSTER_EVENT_MSG, strlen(ClusterProtocol::CLUSTER_EVENT_MSG)) == 0; } +bool ClusterManager::isRawMsg(const char* msg) { + // RAW frames must be "RAW:"; enforce the delimiter so we skip things like "RAW_HEARTBEAT". + const std::size_t prefixLen = strlen(ClusterProtocol::RAW_MSG); + if (strncmp(msg, ClusterProtocol::RAW_MSG, prefixLen) != 0) { + return false; + } + return msg[prefixLen] == ':'; +} + void ClusterManager::onDiscovery(const char* /*msg*/) { ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port); String response = String(ClusterProtocol::RESPONSE_MSG) + ":" + ctx.hostname; @@ -240,6 +250,23 @@ void ClusterManager::onClusterEvent(const char* msg) { ctx.fire(eventKey, &data); } +void ClusterManager::onRawMessage(const char* msg) { + const std::size_t prefixLen = strlen(ClusterProtocol::RAW_MSG); + if (msg[prefixLen] != ':') { + LOG_WARN("Cluster", "RAW message received without payload delimiter"); + return; + } + + const char* payloadStart = msg + prefixLen + 1; + if (*payloadStart == '\0') { + LOG_WARN("Cluster", "RAW message received with empty payload"); + return; + } + + String payload(payloadStart); + ctx.fire("udp/raw", &payload); +} + void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) { auto& memberList = *ctx.memberList; diff --git a/test/package.json b/test/package.json index 31fef03..f22bb4b 100644 --- a/test/package.json +++ b/test/package.json @@ -1,5 +1,10 @@ { "dependencies": { "ws": "^8.18.3" + }, + "scripts": { + "pixelstream:fade-green-blue": "node pixelstream/fade-green-blue.js", + "pixelstream:bouncing-ball": "node pixelstream/bouncing-ball.js", + "pixelstream:rainbow": "node pixelstream/rainbow.js" } } diff --git a/test/pixelstream/bouncing-ball.js b/test/pixelstream/bouncing-ball.js new file mode 100644 index 0000000..f726d26 --- /dev/null +++ b/test/pixelstream/bouncing-ball.js @@ -0,0 +1,80 @@ +const dgram = require('dgram'); + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || '4210', 10); +const pixels = parseInt(process.argv[4] || '64', 10); +const intervalMs = parseInt(process.argv[5] || '30', 10); + +if (!host) { + console.error('Usage: node bouncing-ball.js [port] [pixels] [interval-ms]'); + process.exit(1); +} + +const socket = dgram.createSocket('udp4'); +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); + +let position = Math.random() * (pixels - 1); +let velocity = randomVelocity(); + +function randomVelocity() { + const min = 0.15; + const max = 0.4; + const sign = Math.random() < 0.5 ? -1 : 1; + return (min + Math.random() * (max - min)) * sign; +} + +function rebound(sign) { + velocity = randomVelocity() * sign; +} + +function mix(a, b, t) { + return a + (b - a) * t; +} + +function generateFrame() { + const dt = intervalMs / 1000; + position += velocity * dt * 60; // scale velocity to 60 FPS reference + + if (position < 0) { + position = -position; + rebound(1); + } else if (position > pixels - 1) { + position = (pixels - 1) - (position - (pixels - 1)); + rebound(-1); + } + + const activeIndex = Math.max(0, Math.min(pixels - 1, Math.round(position))); + + let payload = 'RAW:'; + for (let i = 0; i < pixels; i++) { + if (i === activeIndex) { + payload += 'ff8000'; + continue; + } + + const distance = Math.abs(i - position); + const intensity = Math.max(0, 1 - distance); + const green = Math.round(mix(20, 200, intensity)).toString(16).padStart(2, '0'); + const blue = Math.round(mix(40, 255, intensity)).toString(16).padStart(2, '0'); + payload += '00' + green + blue; + } + + return payload; +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); +} + +setInterval(sendFrame, intervalMs); + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +console.log(`Streaming bouncing ball pattern to ${host}:${port} with ${pixels} pixels (interval=${intervalMs}ms)`); + diff --git a/test/pixelstream/fade-green-blue.js b/test/pixelstream/fade-green-blue.js new file mode 100644 index 0000000..eef3433 --- /dev/null +++ b/test/pixelstream/fade-green-blue.js @@ -0,0 +1,55 @@ +const dgram = require('dgram'); + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || '4210', 10); +const pixels = parseInt(process.argv[4] || '64', 10); +const speed = parseFloat(process.argv[5] || '0.5'); // cycles per second + +if (!host) { + console.error('Usage: node fade-green-blue.js [port] [pixels] [speed-hz]'); + process.exit(1); +} + +const socket = dgram.createSocket('udp4'); +const intervalMs = 50; +let tick = 0; +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); + +function generateFrame() { + const timeSeconds = (tick * intervalMs) / 1000; + const phase = timeSeconds * speed * Math.PI * 2; + const blend = (Math.sin(phase) + 1) * 0.5; // 0..1 + + const green = Math.round(255 * (1 - blend)); + const blue = Math.round(255 * blend); + + let payload = 'RAW:'; + const gHex = green.toString(16).padStart(2, '0'); + const bHex = blue.toString(16).padStart(2, '0'); + + for (let i = 0; i < pixels; i++) { + payload += '00'; + payload += gHex; + payload += bHex; + } + + return payload; +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); + tick += 1; +} + +setInterval(sendFrame, intervalMs); + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +console.log(`Streaming green/blue fade to ${host}:${port} with ${pixels} pixels (speed=${speed}Hz)`); + diff --git a/test/pixelstream/rainbow.js b/test/pixelstream/rainbow.js new file mode 100644 index 0000000..f2d42ff --- /dev/null +++ b/test/pixelstream/rainbow.js @@ -0,0 +1,59 @@ +const dgram = require('dgram'); + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || '4210', 10); +const pixels = parseInt(process.argv[4] || '64', 10); +const intervalMs = parseInt(process.argv[5] || '30', 10); + +if (!host) { + console.error('Usage: node rainbow.js [port] [pixels] [interval-ms]'); + process.exit(1); +} + +const socket = dgram.createSocket('udp4'); +let offset = 0; +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); + +function wheel(pos) { + pos = 255 - pos; + if (pos < 85) { + return [255 - pos * 3, 0, pos * 3]; + } + if (pos < 170) { + pos -= 85; + return [0, pos * 3, 255 - pos * 3]; + } + pos -= 170; + return [pos * 3, 255 - pos * 3, 0]; +} + +function generateFrame() { + let payload = 'RAW:'; + for (let i = 0; i < pixels; i++) { + const colorIndex = (i * 256 / pixels + offset) & 255; + const [r, g, b] = wheel(colorIndex); + payload += r.toString(16).padStart(2, '0'); + payload += g.toString(16).padStart(2, '0'); + payload += b.toString(16).padStart(2, '0'); + } + + offset = (offset + 1) & 255; + return payload; +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); +} + +setInterval(sendFrame, intervalMs); + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +console.log(`Streaming rainbow pattern to ${host}:${port} with ${pixels} pixels (interval=${intervalMs}ms)`); + -- 2.49.1 From f78dd8b843b66b33bc1b069498c0b2242e4343e8 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Thu, 2 Oct 2025 21:36:46 +0200 Subject: [PATCH 2/5] feat: matrix stream example --- examples/pixelstream/main.cpp | 8 +- include/spore/internal/Globals.h | 4 +- platformio.ini | 21 +++ test/package.json | 3 +- test/pixelstream/lava-lamp.js | 221 +++++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 test/pixelstream/lava-lamp.js diff --git a/examples/pixelstream/main.cpp b/examples/pixelstream/main.cpp index 1685d2e..1b2a77f 100644 --- a/examples/pixelstream/main.cpp +++ b/examples/pixelstream/main.cpp @@ -8,19 +8,19 @@ #endif #ifndef PIXEL_COUNT -#define PIXEL_COUNT 64 +#define PIXEL_COUNT 16 #endif #ifndef PIXEL_BRIGHTNESS -#define PIXEL_BRIGHTNESS 80 +#define PIXEL_BRIGHTNESS 50 #endif #ifndef PIXEL_MATRIX_WIDTH -#define PIXEL_MATRIX_WIDTH 0 +#define PIXEL_MATRIX_WIDTH 16 #endif #ifndef PIXEL_MATRIX_SERPENTINE -#define PIXEL_MATRIX_SERPENTINE 1 +#define PIXEL_MATRIX_SERPENTINE 0 #endif #ifndef PIXEL_TYPE diff --git a/include/spore/internal/Globals.h b/include/spore/internal/Globals.h index ebb0fc8..e2468b4 100644 --- a/include/spore/internal/Globals.h +++ b/include/spore/internal/Globals.h @@ -12,8 +12,8 @@ namespace ClusterProtocol { constexpr const char* CLUSTER_EVENT_MSG = "CLUSTER_EVENT"; constexpr const char* RAW_MSG = "RAW"; constexpr uint16_t UDP_PORT = 4210; - // Increased buffer to accommodate node info JSON over UDP - constexpr size_t UDP_BUF_SIZE = 512; + // Increased buffer to accommodate larger RAW pixel streams and node info JSON over UDP + constexpr size_t UDP_BUF_SIZE = 2048; constexpr const char* API_NODE_STATUS = "/api/node/status"; } diff --git a/platformio.ini b/platformio.ini index ac9cce8..6fa4b77 100644 --- a/platformio.ini +++ b/platformio.ini @@ -121,3 +121,24 @@ build_src_filter = + + + + +[env:pixelstream_d1] +platform = platformio/espressif8266@^4.2.1 +board = d1_mini +framework = arduino +upload_speed = 115200 +monitor_speed = 115200 +board_build.filesystem = littlefs +board_build.flash_mode = dout +board_build.ldscript = eagle.flash.1m256.ld +lib_deps = ${common.lib_deps} + adafruit/Adafruit NeoPixel@^1.15.1 +build_flags = -DPIXEL_PIN=TX -DPIXEL_COUNT=256 -DMATRIX_WIDTH=16 +build_src_filter = + + + + + + + + + + + + + + diff --git a/test/package.json b/test/package.json index f22bb4b..db350ab 100644 --- a/test/package.json +++ b/test/package.json @@ -5,6 +5,7 @@ "scripts": { "pixelstream:fade-green-blue": "node pixelstream/fade-green-blue.js", "pixelstream:bouncing-ball": "node pixelstream/bouncing-ball.js", - "pixelstream:rainbow": "node pixelstream/rainbow.js" + "pixelstream:rainbow": "node pixelstream/rainbow.js", + "pixelstream:lava-lamp": "node pixelstream/lava-lamp.js" } } diff --git a/test/pixelstream/lava-lamp.js b/test/pixelstream/lava-lamp.js new file mode 100644 index 0000000..57b5fac --- /dev/null +++ b/test/pixelstream/lava-lamp.js @@ -0,0 +1,221 @@ +const dgram = require('dgram'); + +const DEFAULT_PORT = 4210; +const DEFAULT_WIDTH = 16; +const DEFAULT_HEIGHT = 16; +const DEFAULT_INTERVAL_MS = 60; +const DEFAULT_BLOB_COUNT = 6; +const SERPENTINE_WIRING = true; +const BASE_BLOB_SPEED = 0.18; +const PHASE_SPEED_MIN = 0.6; +const PHASE_SPEED_MAX = 1.2; + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10); +const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10); +const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10); +const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10); +const blobCount = parseInt(process.argv[7] || String(DEFAULT_BLOB_COUNT), 10); + +if (!host) { + console.error('Usage: node lava-lamp.js [port] [width] [height] [interval-ms] [blob-count]'); + process.exit(1); +} + +if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(blobCount)) { + console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and blob-count.'); + process.exit(1); +} + +if (width <= 0 || height <= 0) { + console.error('Matrix dimensions must be positive integers.'); + process.exit(1); +} + +if (blobCount <= 0) { + console.error('Blob count must be a positive integer.'); + process.exit(1); +} + +const totalPixels = width * height; +const socket = dgram.createSocket('udp4'); +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); + +const maxAxis = Math.max(width, height); +const minBlobRadius = Math.max(3, maxAxis * 0.18); +const maxBlobRadius = Math.max(minBlobRadius + 1, maxAxis * 0.38); +const frameTimeSeconds = intervalMs / 1000; + +const paletteStops = [ + { stop: 0.0, color: hexToRgb('050319') }, + { stop: 0.28, color: hexToRgb('2a0c4f') }, + { stop: 0.55, color: hexToRgb('8f1f73') }, + { stop: 0.75, color: hexToRgb('ff4a22') }, + { stop: 0.9, color: hexToRgb('ff9333') }, + { stop: 1.0, color: hexToRgb('fff7b0') }, +]; + +const blobs = createBlobs(blobCount); + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +socket.on('error', (error) => { + console.error('Socket error:', error.message); +}); + +function createBlobs(count) { + const blobList = []; + for (let index = 0; index < count; ++index) { + const angle = Math.random() * Math.PI * 2; + const speed = BASE_BLOB_SPEED * (0.6 + Math.random() * 0.8); + blobList.push({ + x: Math.random() * Math.max(1, width - 1), + y: Math.random() * Math.max(1, height - 1), + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + minRadius: minBlobRadius * (0.6 + Math.random() * 0.3), + maxRadius: maxBlobRadius * (0.8 + Math.random() * 0.4), + intensity: 0.8 + Math.random() * 0.7, + phase: Math.random() * Math.PI * 2, + phaseVelocity: PHASE_SPEED_MIN + Math.random() * (PHASE_SPEED_MAX - PHASE_SPEED_MIN), + }); + } + return blobList; +} + +function updateBlobs(deltaSeconds) { + const maxX = Math.max(0, width - 1); + const maxY = Math.max(0, height - 1); + + blobs.forEach((blob) => { + blob.x += blob.vx * deltaSeconds; + blob.y += blob.vy * deltaSeconds; + + if (blob.x < 0) { + blob.x = -blob.x; + blob.vx = Math.abs(blob.vx); + } else if (blob.x > maxX) { + blob.x = 2 * maxX - blob.x; + blob.vx = -Math.abs(blob.vx); + } + + if (blob.y < 0) { + blob.y = -blob.y; + blob.vy = Math.abs(blob.vy); + } else if (blob.y > maxY) { + blob.y = 2 * maxY - blob.y; + blob.vy = -Math.abs(blob.vy); + } + + blob.phase += blob.phaseVelocity * deltaSeconds; + }); +} + +function generateFrame() { + updateBlobs(frameTimeSeconds); + + const frame = new Array(totalPixels); + + for (let row = 0; row < height; ++row) { + for (let col = 0; col < width; ++col) { + const energy = calculateEnergyAt(col, row); + const color = samplePalette(energy); + frame[toIndex(col, row)] = color; + } + } + + return 'RAW:' + frame.join(''); +} + +function calculateEnergyAt(col, row) { + let energy = 0; + + blobs.forEach((blob) => { + const radius = getBlobRadius(blob); + const dx = col - blob.x; + const dy = row - blob.y; + const distance = Math.hypot(dx, dy); + const falloff = Math.max(0, 1 - distance / radius); + energy += blob.intensity * falloff * falloff; + }); + + return clamp(energy / blobs.length, 0, 1); +} + +function getBlobRadius(blob) { + const oscillation = (Math.sin(blob.phase) + 1) * 0.5; + return blob.minRadius + (blob.maxRadius - blob.minRadius) * oscillation; +} + +function toIndex(col, row) { + if (!SERPENTINE_WIRING || row % 2 === 0) { + return row * width + col; + } + return row * width + (width - 1 - col); +} + +function samplePalette(value) { + const clampedValue = clamp(value, 0, 1); + + for (let index = 0; index < paletteStops.length - 1; ++index) { + const left = paletteStops[index]; + const right = paletteStops[index + 1]; + if (clampedValue <= right.stop) { + const span = right.stop - left.stop || 1; + const t = clamp((clampedValue - left.stop) / span, 0, 1); + const interpolatedColor = lerpRgb(left.color, right.color, t); + return rgbToHex(interpolatedColor); + } + } + + return rgbToHex(paletteStops[paletteStops.length - 1].color); +} + +function lerpRgb(lhs, rhs, t) { + return { + r: Math.round(lhs.r + (rhs.r - lhs.r) * t), + g: Math.round(lhs.g + (rhs.g - lhs.g) * t), + b: Math.round(lhs.b + (rhs.b - lhs.b) * t), + }; +} + +function hexToRgb(hex) { + const normalizedHex = hex.trim().toLowerCase(); + const value = normalizedHex.startsWith('#') ? normalizedHex.slice(1) : normalizedHex; + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + }; +} + +function rgbToHex(rgb) { + return toHex(rgb.r) + toHex(rgb.g) + toHex(rgb.b); +} + +function toHex(value) { + const boundedValue = clamp(Math.round(value), 0, 255); + const hex = boundedValue.toString(16); + return hex.length === 1 ? '0' + hex : hex; +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); +} + +setInterval(sendFrame, intervalMs); + +console.log( + `Streaming lava lamp to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, blobs=${blobCount})`, +); + -- 2.49.1 From d1fb5fc96e9fffd7b847b957259e625992b0f2e6 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Thu, 2 Oct 2025 21:46:51 +0200 Subject: [PATCH 3/5] feat: more matrix stream examples --- test/package.json | 5 +- test/pixelstream/lava-lamp.js | 68 ++----------- test/pixelstream/meteor-rain.js | 132 +++++++++++++++++++++++++ test/pixelstream/ocean-glimmer.js | 95 ++++++++++++++++++ test/pixelstream/shared-frame-utils.js | 89 +++++++++++++++++ test/pixelstream/spiral-bloom.js | 97 ++++++++++++++++++ 6 files changed, 426 insertions(+), 60 deletions(-) create mode 100644 test/pixelstream/meteor-rain.js create mode 100644 test/pixelstream/ocean-glimmer.js create mode 100644 test/pixelstream/shared-frame-utils.js create mode 100644 test/pixelstream/spiral-bloom.js diff --git a/test/package.json b/test/package.json index db350ab..cdec9a1 100644 --- a/test/package.json +++ b/test/package.json @@ -6,6 +6,9 @@ "pixelstream:fade-green-blue": "node pixelstream/fade-green-blue.js", "pixelstream:bouncing-ball": "node pixelstream/bouncing-ball.js", "pixelstream:rainbow": "node pixelstream/rainbow.js", - "pixelstream:lava-lamp": "node pixelstream/lava-lamp.js" + "pixelstream:lava-lamp": "node pixelstream/lava-lamp.js", + "pixelstream:meteor-rain": "node pixelstream/meteor-rain.js", + "pixelstream:spiral-bloom": "node pixelstream/spiral-bloom.js", + "pixelstream:ocean-glimmer": "node pixelstream/ocean-glimmer.js" } } diff --git a/test/pixelstream/lava-lamp.js b/test/pixelstream/lava-lamp.js index 57b5fac..e80aa20 100644 --- a/test/pixelstream/lava-lamp.js +++ b/test/pixelstream/lava-lamp.js @@ -1,11 +1,17 @@ const dgram = require('dgram'); +const { + clamp, + hexToRgb, + samplePalette: samplePaletteFromStops, + toIndex, +} = require('./shared-frame-utils'); + const DEFAULT_PORT = 4210; const DEFAULT_WIDTH = 16; const DEFAULT_HEIGHT = 16; const DEFAULT_INTERVAL_MS = 60; const DEFAULT_BLOB_COUNT = 6; -const SERPENTINE_WIRING = true; const BASE_BLOB_SPEED = 0.18; const PHASE_SPEED_MIN = 0.6; const PHASE_SPEED_MAX = 1.2; @@ -123,8 +129,8 @@ function generateFrame() { for (let row = 0; row < height; ++row) { for (let col = 0; col < width; ++col) { const energy = calculateEnergyAt(col, row); - const color = samplePalette(energy); - frame[toIndex(col, row)] = color; + const color = samplePaletteFromStops(paletteStops, energy); + frame[toIndex(col, row, width)] = color; } } @@ -151,62 +157,6 @@ function getBlobRadius(blob) { return blob.minRadius + (blob.maxRadius - blob.minRadius) * oscillation; } -function toIndex(col, row) { - if (!SERPENTINE_WIRING || row % 2 === 0) { - return row * width + col; - } - return row * width + (width - 1 - col); -} - -function samplePalette(value) { - const clampedValue = clamp(value, 0, 1); - - for (let index = 0; index < paletteStops.length - 1; ++index) { - const left = paletteStops[index]; - const right = paletteStops[index + 1]; - if (clampedValue <= right.stop) { - const span = right.stop - left.stop || 1; - const t = clamp((clampedValue - left.stop) / span, 0, 1); - const interpolatedColor = lerpRgb(left.color, right.color, t); - return rgbToHex(interpolatedColor); - } - } - - return rgbToHex(paletteStops[paletteStops.length - 1].color); -} - -function lerpRgb(lhs, rhs, t) { - return { - r: Math.round(lhs.r + (rhs.r - lhs.r) * t), - g: Math.round(lhs.g + (rhs.g - lhs.g) * t), - b: Math.round(lhs.b + (rhs.b - lhs.b) * t), - }; -} - -function hexToRgb(hex) { - const normalizedHex = hex.trim().toLowerCase(); - const value = normalizedHex.startsWith('#') ? normalizedHex.slice(1) : normalizedHex; - return { - r: parseInt(value.slice(0, 2), 16), - g: parseInt(value.slice(2, 4), 16), - b: parseInt(value.slice(4, 6), 16), - }; -} - -function rgbToHex(rgb) { - return toHex(rgb.r) + toHex(rgb.g) + toHex(rgb.b); -} - -function toHex(value) { - const boundedValue = clamp(Math.round(value), 0, 255); - const hex = boundedValue.toString(16); - return hex.length === 1 ? '0' + hex : hex; -} - -function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - function sendFrame() { const payload = generateFrame(); const message = Buffer.from(payload, 'utf8'); diff --git a/test/pixelstream/meteor-rain.js b/test/pixelstream/meteor-rain.js new file mode 100644 index 0000000..0ac8bbb --- /dev/null +++ b/test/pixelstream/meteor-rain.js @@ -0,0 +1,132 @@ +const dgram = require('dgram'); + +const { + clamp, + createFrame, + fadeFrame, + frameToPayload, + hexToRgb, + samplePalette, + toIndex, +} = require('./shared-frame-utils'); + +const DEFAULT_PORT = 4210; +const DEFAULT_WIDTH = 16; +const DEFAULT_HEIGHT = 16; +const DEFAULT_INTERVAL_MS = 45; +const DEFAULT_METEOR_COUNT = 12; +const BASE_SPEED_MIN = 4; +const BASE_SPEED_MAX = 10; +const TRAIL_DECAY = 0.76; + +const paletteStops = [ + { stop: 0.0, color: hexToRgb('0a0126') }, + { stop: 0.3, color: hexToRgb('123d8b') }, + { stop: 0.7, color: hexToRgb('21c7d9') }, + { stop: 1.0, color: hexToRgb('f7ffff') }, +]; + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10); +const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10); +const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10); +const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10); +const meteorCount = parseInt(process.argv[7] || String(DEFAULT_METEOR_COUNT), 10); + +if (!host) { + console.error('Usage: node meteor-rain.js [port] [width] [height] [interval-ms] [meteor-count]'); + process.exit(1); +} + +if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(meteorCount)) { + console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and meteor-count.'); + process.exit(1); +} + +if (width <= 0 || height <= 0) { + console.error('Matrix dimensions must be positive integers.'); + process.exit(1); +} + +if (meteorCount <= 0) { + console.error('Meteor count must be a positive integer.'); + process.exit(1); +} + +const socket = dgram.createSocket('udp4'); +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); +const frame = createFrame(width, height); +const meteors = createMeteors(meteorCount, width, height); +const frameTimeSeconds = intervalMs / 1000; + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +socket.on('error', (error) => { + console.error('Socket error:', error.message); +}); + +function createMeteors(count, matrixWidth, matrixHeight) { + const meteorList = []; + for (let index = 0; index < count; ++index) { + meteorList.push(spawnMeteor(matrixWidth, matrixHeight)); + } + return meteorList; +} + +function spawnMeteor(matrixWidth, matrixHeight) { + const angle = (Math.PI / 4) * (0.6 + Math.random() * 0.8); + const speed = BASE_SPEED_MIN + Math.random() * (BASE_SPEED_MAX - BASE_SPEED_MIN); + return { + x: Math.random() * matrixWidth, + y: -Math.random() * matrixHeight, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + }; +} + +function drawMeteor(meteor) { + const col = Math.round(meteor.x); + const row = Math.round(meteor.y); + if (col < 0 || col >= width || row < 0 || row >= height) { + return; + } + + const energy = clamp(1.2 - Math.random() * 0.2, 0, 1); + frame[toIndex(col, row, width)] = samplePalette(paletteStops, energy); +} + +function updateMeteors(deltaSeconds) { + meteors.forEach((meteor, index) => { + meteor.x += meteor.vx * deltaSeconds; + meteor.y += meteor.vy * deltaSeconds; + + drawMeteor(meteor); + + if (meteor.x > width + 1 || meteor.y > height + 1) { + meteors[index] = spawnMeteor(width, height); + } + }); +} + +function generateFrame() { + fadeFrame(frame, TRAIL_DECAY); + updateMeteors(frameTimeSeconds); + return frameToPayload(frame); +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); +} + +setInterval(sendFrame, intervalMs); + +console.log( + `Streaming meteor rain to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, meteors=${meteorCount})`, +); + diff --git a/test/pixelstream/ocean-glimmer.js b/test/pixelstream/ocean-glimmer.js new file mode 100644 index 0000000..b129c23 --- /dev/null +++ b/test/pixelstream/ocean-glimmer.js @@ -0,0 +1,95 @@ +const dgram = require('dgram'); + +const { + clamp, + createFrame, + frameToPayload, + hexToRgb, + samplePalette, + toIndex, +} = require('./shared-frame-utils'); + +const DEFAULT_PORT = 4210; +const DEFAULT_WIDTH = 16; +const DEFAULT_HEIGHT = 16; +const DEFAULT_INTERVAL_MS = 50; +const SHIMMER = 0.08; + +const paletteStops = [ + { stop: 0.0, color: hexToRgb('031521') }, + { stop: 0.35, color: hexToRgb('024f6d') }, + { stop: 0.65, color: hexToRgb('13a4a1') }, + { stop: 0.85, color: hexToRgb('67dcd0') }, + { stop: 1.0, color: hexToRgb('fcdba4') }, +]; + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10); +const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10); +const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10); +const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10); + +if (!host) { + console.error('Usage: node ocean-glimmer.js [port] [width] [height] [interval-ms]'); + process.exit(1); +} + +if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) { + console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.'); + process.exit(1); +} + +if (width <= 0 || height <= 0) { + console.error('Matrix dimensions must be positive integers.'); + process.exit(1); +} + +const socket = dgram.createSocket('udp4'); +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); +const frame = createFrame(width, height); +let timeSeconds = 0; +const frameTimeSeconds = intervalMs / 1000; + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +socket.on('error', (error) => { + console.error('Socket error:', error.message); +}); + +function generateFrame() { + timeSeconds += frameTimeSeconds; + + for (let row = 0; row < height; ++row) { + const v = row / Math.max(1, height - 1); + for (let col = 0; col < width; ++col) { + const u = col / Math.max(1, width - 1); + const base = + 0.33 + + 0.26 * Math.sin(u * Math.PI * 2 + timeSeconds * 1.2) + + 0.26 * Math.sin(v * Math.PI * 2 - timeSeconds * 0.9) + + 0.26 * Math.sin((u + v) * Math.PI * 2 + timeSeconds * 0.5); + const noise = (Math.random() - 0.5) * SHIMMER; + const value = clamp(base + noise, 0, 1); + frame[toIndex(col, row, width)] = samplePalette(paletteStops, value); + } + } + + return frameToPayload(frame); +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); +} + +setInterval(sendFrame, intervalMs); + +console.log( + `Streaming ocean glimmer to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`, +); + diff --git a/test/pixelstream/shared-frame-utils.js b/test/pixelstream/shared-frame-utils.js new file mode 100644 index 0000000..d9d7a2c --- /dev/null +++ b/test/pixelstream/shared-frame-utils.js @@ -0,0 +1,89 @@ +const SERPENTINE_WIRING = true; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function hexToRgb(hex) { + const normalizedHex = hex.trim().toLowerCase(); + const value = normalizedHex.startsWith('#') ? normalizedHex.slice(1) : normalizedHex; + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + }; +} + +function rgbToHex(rgb) { + return toHex(rgb.r) + toHex(rgb.g) + toHex(rgb.b); +} + +function toHex(value) { + const boundedValue = clamp(Math.round(value), 0, 255); + const hex = boundedValue.toString(16); + return hex.length === 1 ? '0' + hex : hex; +} + +function lerpRgb(lhs, rhs, t) { + return { + r: Math.round(lhs.r + (rhs.r - lhs.r) * t), + g: Math.round(lhs.g + (rhs.g - lhs.g) * t), + b: Math.round(lhs.b + (rhs.b - lhs.b) * t), + }; +} + +function samplePalette(paletteStops, value) { + const clampedValue = clamp(value, 0, 1); + + for (let index = 0; index < paletteStops.length - 1; ++index) { + const left = paletteStops[index]; + const right = paletteStops[index + 1]; + if (clampedValue <= right.stop) { + const span = right.stop - left.stop || 1; + const t = clamp((clampedValue - left.stop) / span, 0, 1); + const interpolatedColor = lerpRgb(left.color, right.color, t); + return rgbToHex(interpolatedColor); + } + } + + return rgbToHex(paletteStops[paletteStops.length - 1].color); +} + +function toIndex(col, row, width, serpentine = SERPENTINE_WIRING) { + if (!serpentine || row % 2 === 0) { + return row * width + col; + } + return row * width + (width - 1 - col); +} + +function createFrame(width, height, fill = '000000') { + return new Array(width * height).fill(fill); +} + +function frameToPayload(frame) { + return 'RAW:' + frame.join(''); +} + +function fadeFrame(frame, factor) { + for (let index = 0; index < frame.length; ++index) { + const hex = frame[index]; + const r = parseInt(hex.slice(0, 2), 16) * factor; + const g = parseInt(hex.slice(2, 4), 16) * factor; + const b = parseInt(hex.slice(4, 6), 16) * factor; + frame[index] = toHex(r) + toHex(g) + toHex(b); + } +} + +module.exports = { + SERPENTINE_WIRING, + clamp, + hexToRgb, + rgbToHex, + lerpRgb, + samplePalette, + toIndex, + createFrame, + frameToPayload, + fadeFrame, +}; + diff --git a/test/pixelstream/spiral-bloom.js b/test/pixelstream/spiral-bloom.js new file mode 100644 index 0000000..2b6da80 --- /dev/null +++ b/test/pixelstream/spiral-bloom.js @@ -0,0 +1,97 @@ +const dgram = require('dgram'); + +const { + createFrame, + frameToPayload, + hexToRgb, + samplePalette, + toIndex, +} = require('./shared-frame-utils'); + +const DEFAULT_PORT = 4210; +const DEFAULT_WIDTH = 16; +const DEFAULT_HEIGHT = 16; +const DEFAULT_INTERVAL_MS = 55; + +const paletteStops = [ + { stop: 0.0, color: hexToRgb('051923') }, + { stop: 0.2, color: hexToRgb('0c4057') }, + { stop: 0.45, color: hexToRgb('1d7a70') }, + { stop: 0.7, color: hexToRgb('39b15f') }, + { stop: 0.88, color: hexToRgb('9dd54c') }, + { stop: 1.0, color: hexToRgb('f7f5bc') }, +]; + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10); +const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10); +const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10); +const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10); + +if (!host) { + console.error('Usage: node spiral-bloom.js [port] [width] [height] [interval-ms]'); + process.exit(1); +} + +if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) { + console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.'); + process.exit(1); +} + +if (width <= 0 || height <= 0) { + console.error('Matrix dimensions must be positive integers.'); + process.exit(1); +} + +const socket = dgram.createSocket('udp4'); +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); +const frame = createFrame(width, height); +let rotation = 0; +let hueShift = 0; +const frameTimeSeconds = intervalMs / 1000; + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +socket.on('error', (error) => { + console.error('Socket error:', error.message); +}); + +function generateFrame() { + rotation += frameTimeSeconds * 0.7; + hueShift += frameTimeSeconds * 0.2; + + const cx = (width - 1) / 2; + const cy = (height - 1) / 2; + const radiusNorm = Math.hypot(cx, cy) || 1; + + for (let row = 0; row < height; ++row) { + for (let col = 0; col < width; ++col) { + const dx = col - cx; + const dy = row - cy; + const radius = Math.hypot(dx, dy) / radiusNorm; + const angle = Math.atan2(dy, dx); + const arm = 0.5 + 0.5 * Math.sin(5 * (angle + rotation) + hueShift * Math.PI * 2); + const value = Math.min(1, radius * 0.8 + arm * 0.4); + frame[toIndex(col, row, width)] = samplePalette(paletteStops, value); + } + } + + return frameToPayload(frame); +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); +} + +setInterval(sendFrame, intervalMs); + +console.log( + `Streaming spiral bloom to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`, +); + -- 2.49.1 From 1383f6d32f2b8711e328ce6562ee9dceb7e3cbda Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Thu, 2 Oct 2025 22:08:40 +0200 Subject: [PATCH 4/5] feat: even more matrix stream examples --- examples/pixelstream/main.cpp | 2 +- test/package.json | 7 +- test/pixelstream/aurora-curtains.js | 115 +++++++++++++++++ test/pixelstream/circuit-pulse.js | 166 +++++++++++++++++++++++++ test/pixelstream/nebula-drift.js | 104 ++++++++++++++++ test/pixelstream/shared-frame-utils.js | 29 +++++ test/pixelstream/voxel-fireflies.js | 165 ++++++++++++++++++++++++ test/pixelstream/wormhole-tunnel.js | 108 ++++++++++++++++ 8 files changed, 694 insertions(+), 2 deletions(-) create mode 100644 test/pixelstream/aurora-curtains.js create mode 100644 test/pixelstream/circuit-pulse.js create mode 100644 test/pixelstream/nebula-drift.js create mode 100644 test/pixelstream/voxel-fireflies.js create mode 100644 test/pixelstream/wormhole-tunnel.js diff --git a/examples/pixelstream/main.cpp b/examples/pixelstream/main.cpp index 1b2a77f..f053938 100644 --- a/examples/pixelstream/main.cpp +++ b/examples/pixelstream/main.cpp @@ -12,7 +12,7 @@ #endif #ifndef PIXEL_BRIGHTNESS -#define PIXEL_BRIGHTNESS 50 +#define PIXEL_BRIGHTNESS 80 #endif #ifndef PIXEL_MATRIX_WIDTH diff --git a/test/package.json b/test/package.json index cdec9a1..1cb5be8 100644 --- a/test/package.json +++ b/test/package.json @@ -9,6 +9,11 @@ "pixelstream:lava-lamp": "node pixelstream/lava-lamp.js", "pixelstream:meteor-rain": "node pixelstream/meteor-rain.js", "pixelstream:spiral-bloom": "node pixelstream/spiral-bloom.js", - "pixelstream:ocean-glimmer": "node pixelstream/ocean-glimmer.js" + "pixelstream:ocean-glimmer": "node pixelstream/ocean-glimmer.js", + "pixelstream:nebula-drift": "node pixelstream/nebula-drift.js", + "pixelstream:voxel-fireflies": "node pixelstream/voxel-fireflies.js", + "pixelstream:wormhole-tunnel": "node pixelstream/wormhole-tunnel.js", + "pixelstream:circuit-pulse": "node pixelstream/circuit-pulse.js", + "pixelstream:aurora-curtains": "node pixelstream/aurora-curtains.js" } } diff --git a/test/pixelstream/aurora-curtains.js b/test/pixelstream/aurora-curtains.js new file mode 100644 index 0000000..0f7a551 --- /dev/null +++ b/test/pixelstream/aurora-curtains.js @@ -0,0 +1,115 @@ +const dgram = require('dgram'); + +const { + clamp, + createFrame, + frameToPayload, + hexToRgb, + samplePalette, + toIndex, +} = require('./shared-frame-utils'); + +const DEFAULT_PORT = 4210; +const DEFAULT_WIDTH = 16; +const DEFAULT_HEIGHT = 16; +const DEFAULT_INTERVAL_MS = 65; +const BAND_COUNT = 5; +const WAVE_SPEED = 0.35; +const HORIZONTAL_SWAY = 0.45; + +const paletteStops = [ + { stop: 0.0, color: hexToRgb('01010a') }, + { stop: 0.2, color: hexToRgb('041332') }, + { stop: 0.4, color: hexToRgb('0c3857') }, + { stop: 0.65, color: hexToRgb('1aa07a') }, + { stop: 0.85, color: hexToRgb('68d284') }, + { stop: 1.0, color: hexToRgb('f4f5c6') }, +]; + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10); +const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10); +const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10); +const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10); + +if (!host) { + console.error('Usage: node aurora-curtains.js [port] [width] [height] [interval-ms]'); + process.exit(1); +} + +if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) { + console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.'); + process.exit(1); +} + +if (width <= 0 || height <= 0) { + console.error('Matrix dimensions must be positive integers.'); + process.exit(1); +} + +const socket = dgram.createSocket('udp4'); +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); +const frame = createFrame(width, height); +const bands = createBands(BAND_COUNT, width); +let timeSeconds = 0; +const frameTimeSeconds = intervalMs / 1000; + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +socket.on('error', (error) => { + console.error('Socket error:', error.message); +}); + +function createBands(count, matrixWidth) { + const generatedBands = []; + for (let index = 0; index < count; ++index) { + generatedBands.push({ + center: Math.random() * (matrixWidth - 1), + phase: Math.random() * Math.PI * 2, + width: 1.2 + Math.random() * 1.8, + }); + } + return generatedBands; +} + +function generateFrame() { + timeSeconds += frameTimeSeconds; + + for (let row = 0; row < height; ++row) { + const verticalRatio = row / Math.max(1, height - 1); + for (let col = 0; col < width; ++col) { + let intensity = 0; + + bands.forEach((band, index) => { + const sway = Math.sin(timeSeconds * WAVE_SPEED + band.phase + verticalRatio * Math.PI * 2) * HORIZONTAL_SWAY; + const center = band.center + sway * (index % 2 === 0 ? 1 : -1); + const distance = Math.abs(col - center); + const blurred = Math.exp(-(distance * distance) / (2 * band.width * band.width)); + intensity += blurred * (0.8 + Math.sin(timeSeconds * 0.4 + index) * 0.2); + }); + + const normalized = clamp(intensity / bands.length, 0, 1); + const gradientBlend = clamp((normalized * 0.7 + verticalRatio * 0.3), 0, 1); + frame[toIndex(col, row, width)] = samplePalette(paletteStops, gradientBlend); + } + } + + return frameToPayload(frame); +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); +} + +setInterval(sendFrame, intervalMs); + +console.log( + `Streaming aurora curtains to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)` +); + diff --git a/test/pixelstream/circuit-pulse.js b/test/pixelstream/circuit-pulse.js new file mode 100644 index 0000000..10cc954 --- /dev/null +++ b/test/pixelstream/circuit-pulse.js @@ -0,0 +1,166 @@ +const dgram = require('dgram'); + +const { + addHexColor, + createFrame, + fadeFrame, + frameToPayload, + hexToRgb, + samplePalette, + toIndex, +} = require('./shared-frame-utils'); + +const DEFAULT_PORT = 4210; +const DEFAULT_WIDTH = 16; +const DEFAULT_HEIGHT = 16; +const DEFAULT_INTERVAL_MS = 50; +const PATH_FADE = 0.85; +const PULSE_LENGTH = 6; + +const paletteStops = [ + { stop: 0.0, color: hexToRgb('020209') }, + { stop: 0.3, color: hexToRgb('023047') }, + { stop: 0.6, color: hexToRgb('115173') }, + { stop: 0.8, color: hexToRgb('1ca78f') }, + { stop: 1.0, color: hexToRgb('94fdf3') }, +]; + +const accentColors = ['14f5ff', 'a7ff4d', 'ffcc3f']; + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10); +const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10); +const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10); +const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10); + +if (!host) { + console.error('Usage: node circuit-pulse.js [port] [width] [height] [interval-ms]'); + process.exit(1); +} + +if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) { + console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.'); + process.exit(1); +} + +if (width <= 0 || height <= 0) { + console.error('Matrix dimensions must be positive integers.'); + process.exit(1); +} + +const socket = dgram.createSocket('udp4'); +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); +const frame = createFrame(width, height); +const paths = createPaths(width, height); +const pulses = createPulses(paths.length); +const frameTimeSeconds = intervalMs / 1000; + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +socket.on('error', (error) => { + console.error('Socket error:', error.message); +}); + +function createPaths(matrixWidth, matrixHeight) { + const horizontalStep = Math.max(2, Math.floor(matrixHeight / 4)); + const verticalStep = Math.max(2, Math.floor(matrixWidth / 4)); + const generatedPaths = []; + + for (let y = 1; y < matrixHeight; y += horizontalStep) { + const path = []; + for (let x = 0; x < matrixWidth; ++x) { + path.push({ x, y }); + } + generatedPaths.push(path); + } + + for (let x = 2; x < matrixWidth; x += verticalStep) { + const path = []; + for (let y = 0; y < matrixHeight; ++y) { + path.push({ x, y }); + } + generatedPaths.push(path); + } + + return generatedPaths; +} + +function createPulses(count) { + const pulseList = []; + for (let index = 0; index < count; ++index) { + pulseList.push(spawnPulse(index)); + } + return pulseList; +} + +function spawnPulse(pathIndex) { + const color = accentColors[pathIndex % accentColors.length]; + return { + pathIndex, + position: 0, + speed: 3 + Math.random() * 2, + color, + }; +} + +function updatePulse(pulse, deltaSeconds) { + pulse.position += pulse.speed * deltaSeconds; + const path = paths[pulse.pathIndex]; + + if (!path || path.length === 0) { + return; + } + + if (pulse.position >= path.length + PULSE_LENGTH) { + Object.assign(pulse, spawnPulse(pulse.pathIndex)); + pulse.position = 0; + } +} + +function renderPulse(pulse) { + const path = paths[pulse.pathIndex]; + if (!path) { + return; + } + + for (let offset = 0; offset < PULSE_LENGTH; ++offset) { + const index = Math.floor(pulse.position) - offset; + if (index < 0 || index >= path.length) { + continue; + } + + const { x, y } = path[index]; + const intensity = Math.max(0, 1 - offset / PULSE_LENGTH); + const baseColor = samplePalette(paletteStops, intensity); + frame[toIndex(x, y, width)] = baseColor; + addHexColor(frame, toIndex(x, y, width), pulse.color, intensity * 1.4); + } +} + +function generateFrame() { + fadeFrame(frame, PATH_FADE); + + pulses.forEach((pulse) => { + updatePulse(pulse, frameTimeSeconds); + renderPulse(pulse); + }); + + return frameToPayload(frame); +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); +} + +setInterval(sendFrame, intervalMs); + +console.log( + `Streaming circuit pulse to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, paths=${paths.length})` +); + diff --git a/test/pixelstream/nebula-drift.js b/test/pixelstream/nebula-drift.js new file mode 100644 index 0000000..5c9bbd1 --- /dev/null +++ b/test/pixelstream/nebula-drift.js @@ -0,0 +1,104 @@ +const dgram = require('dgram'); + +const { + clamp, + createFrame, + fadeFrame, + frameToPayload, + hexToRgb, + samplePalette, + toIndex, +} = require('./shared-frame-utils'); + +const DEFAULT_PORT = 4210; +const DEFAULT_WIDTH = 16; +const DEFAULT_HEIGHT = 16; +const DEFAULT_INTERVAL_MS = 70; +const PRIMARY_SPEED = 0.15; +const SECONDARY_SPEED = 0.32; +const WAVE_SCALE = 0.75; + +const paletteStops = [ + { stop: 0.0, color: hexToRgb('100406') }, + { stop: 0.25, color: hexToRgb('2e0f1f') }, + { stop: 0.5, color: hexToRgb('6a1731') }, + { stop: 0.7, color: hexToRgb('b63b32') }, + { stop: 0.85, color: hexToRgb('f48b2a') }, + { stop: 1.0, color: hexToRgb('ffe9b0') }, +]; + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10); +const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10); +const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10); +const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10); + +if (!host) { + console.error('Usage: node nebula-drift.js [port] [width] [height] [interval-ms]'); + process.exit(1); +} + +if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) { + console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.'); + process.exit(1); +} + +if (width <= 0 || height <= 0) { + console.error('Matrix dimensions must be positive integers.'); + process.exit(1); +} + +const socket = dgram.createSocket('udp4'); +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); +const frame = createFrame(width, height); +let timeSeconds = 0; +const frameTimeSeconds = intervalMs / 1000; + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +socket.on('error', (error) => { + console.error('Socket error:', error.message); +}); + +function layeredWave(u, v, speed, offset) { + return Math.sin((u * 3 + v * 2) * Math.PI * WAVE_SCALE + timeSeconds * speed + offset); +} + +function generateFrame() { + timeSeconds += frameTimeSeconds; + + for (let row = 0; row < height; ++row) { + const v = row / Math.max(1, height - 1); + for (let col = 0; col < width; ++col) { + const u = col / Math.max(1, width - 1); + const primary = layeredWave(u, v, PRIMARY_SPEED, 0); + const secondary = layeredWave(v, u, SECONDARY_SPEED, Math.PI / 4); + const tertiary = Math.sin((u + v) * Math.PI * 1.5 + timeSeconds * 0.18); + + const combined = 0.45 * primary + 0.35 * secondary + 0.2 * tertiary; + const envelope = Math.sin((u * v) * Math.PI * 2 + timeSeconds * 0.1) * 0.25 + 0.75; + const value = clamp((combined * 0.5 + 0.5) * envelope, 0, 1); + + frame[toIndex(col, row, width)] = samplePalette(paletteStops, value); + } + } + + return frameToPayload(frame); +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); +} + +setInterval(sendFrame, intervalMs); + +console.log( + `Streaming nebula drift to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)` +); + diff --git a/test/pixelstream/shared-frame-utils.js b/test/pixelstream/shared-frame-utils.js index d9d7a2c..3a739b2 100644 --- a/test/pixelstream/shared-frame-utils.js +++ b/test/pixelstream/shared-frame-utils.js @@ -74,6 +74,33 @@ function fadeFrame(frame, factor) { } } +function addRgbToFrame(frame, index, rgb) { + if (index < 0 || index >= frame.length) { + return; + } + + const current = hexToRgb(frame[index]); + const updated = { + r: clamp(current.r + rgb.r, 0, 255), + g: clamp(current.g + rgb.g, 0, 255), + b: clamp(current.b + rgb.b, 0, 255), + }; + frame[index] = rgbToHex(updated); +} + +function addHexColor(frame, index, hexColor, intensity = 1) { + if (intensity <= 0) { + return; + } + + const base = hexToRgb(hexColor); + addRgbToFrame(frame, index, { + r: base.r * intensity, + g: base.g * intensity, + b: base.b * intensity, + }); +} + module.exports = { SERPENTINE_WIRING, clamp, @@ -85,5 +112,7 @@ module.exports = { createFrame, frameToPayload, fadeFrame, + addRgbToFrame, + addHexColor, }; diff --git a/test/pixelstream/voxel-fireflies.js b/test/pixelstream/voxel-fireflies.js new file mode 100644 index 0000000..258faa3 --- /dev/null +++ b/test/pixelstream/voxel-fireflies.js @@ -0,0 +1,165 @@ +const dgram = require('dgram'); + +const { + addHexColor, + clamp, + createFrame, + fadeFrame, + frameToPayload, + hexToRgb, + samplePalette, + toIndex, +} = require('./shared-frame-utils'); + +const DEFAULT_PORT = 4210; +const DEFAULT_WIDTH = 16; +const DEFAULT_HEIGHT = 16; +const DEFAULT_INTERVAL_MS = 55; +const DEFAULT_FIREFLY_COUNT = 18; +const HOVER_SPEED = 0.6; +const GLOW_SPEED = 1.8; +const TRAIL_DECAY = 0.8; + +const paletteStops = [ + { stop: 0.0, color: hexToRgb('02030a') }, + { stop: 0.2, color: hexToRgb('031c2d') }, + { stop: 0.4, color: hexToRgb('053d4a') }, + { stop: 0.6, color: hexToRgb('107b68') }, + { stop: 0.8, color: hexToRgb('14c491') }, + { stop: 1.0, color: hexToRgb('f2ffd2') }, +]; + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10); +const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10); +const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10); +const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10); +const fireflyCount = parseInt(process.argv[7] || String(DEFAULT_FIREFLY_COUNT), 10); + +if (!host) { + console.error('Usage: node voxel-fireflies.js [port] [width] [height] [interval-ms] [firefly-count]'); + process.exit(1); +} + +if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(fireflyCount)) { + console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and firefly-count.'); + process.exit(1); +} + +if (width <= 0 || height <= 0) { + console.error('Matrix dimensions must be positive integers.'); + process.exit(1); +} + +if (fireflyCount <= 0) { + console.error('Firefly count must be a positive integer.'); + process.exit(1); +} + +const socket = dgram.createSocket('udp4'); +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); +const frame = createFrame(width, height); +const fireflies = createFireflies(fireflyCount, width, height); +const frameTimeSeconds = intervalMs / 1000; + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +socket.on('error', (error) => { + console.error('Socket error:', error.message); +}); + +function createFireflies(count, matrixWidth, matrixHeight) { + const list = []; + for (let index = 0; index < count; ++index) { + list.push(spawnFirefly(matrixWidth, matrixHeight)); + } + return list; +} + +function spawnFirefly(matrixWidth, matrixHeight) { + return { + x: Math.random() * (matrixWidth - 1), + y: Math.random() * (matrixHeight - 1), + targetX: Math.random() * (matrixWidth - 1), + targetY: Math.random() * (matrixHeight - 1), + glowPhase: Math.random() * Math.PI * 2, + dwell: 1 + Math.random() * 2, + }; +} + +function updateFirefly(firefly, deltaSeconds) { + const dx = firefly.targetX - firefly.x; + const dy = firefly.targetY - firefly.y; + const distance = Math.hypot(dx, dy); + + if (distance < 0.2) { + firefly.dwell -= deltaSeconds; + if (firefly.dwell <= 0) { + firefly.targetX = Math.random() * (width - 1); + firefly.targetY = Math.random() * (height - 1); + firefly.dwell = 1 + Math.random() * 2; + } + } else { + const speed = HOVER_SPEED * (0.8 + Math.random() * 0.4); + firefly.x += (dx / distance) * speed * deltaSeconds; + firefly.y += (dy / distance) * speed * deltaSeconds; + } + + firefly.glowPhase += deltaSeconds * GLOW_SPEED * (0.7 + Math.random() * 0.6); +} + +function drawFirefly(firefly) { + const baseGlow = (Math.sin(firefly.glowPhase) + 1) * 0.5; + const col = Math.round(firefly.x); + const row = Math.round(firefly.y); + + for (let dy = -1; dy <= 1; ++dy) { + for (let dx = -1; dx <= 1; ++dx) { + const sampleX = col + dx; + const sampleY = row + dy; + if (sampleX < 0 || sampleX >= width || sampleY < 0 || sampleY >= height) { + continue; + } + + const distance = Math.hypot(dx, dy); + const falloff = clamp(1 - distance * 0.7, 0, 1); + const intensity = baseGlow * falloff; + if (intensity <= 0) { + continue; + } + + frame[toIndex(sampleX, sampleY, width)] = samplePalette(paletteStops, intensity); + if (distance === 0) { + addHexColor(frame, toIndex(sampleX, sampleY, width), 'ffd966', intensity * 1.6); + } + } + } +} + +function generateFrame() { + fadeFrame(frame, TRAIL_DECAY); + + fireflies.forEach((firefly) => { + updateFirefly(firefly, frameTimeSeconds); + drawFirefly(firefly); + }); + + return frameToPayload(frame); +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); +} + +setInterval(sendFrame, intervalMs); + +console.log( + `Streaming voxel fireflies to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, fireflies=${fireflyCount})` +); + diff --git a/test/pixelstream/wormhole-tunnel.js b/test/pixelstream/wormhole-tunnel.js new file mode 100644 index 0000000..5432aca --- /dev/null +++ b/test/pixelstream/wormhole-tunnel.js @@ -0,0 +1,108 @@ +const dgram = require('dgram'); + +const { + clamp, + createFrame, + frameToPayload, + hexToRgb, + samplePalette, + toIndex, +} = require('./shared-frame-utils'); + +const DEFAULT_PORT = 4210; +const DEFAULT_WIDTH = 16; +const DEFAULT_HEIGHT = 16; +const DEFAULT_INTERVAL_MS = 60; +const RING_DENSITY = 8; +const RING_SPEED = 1.4; +const RING_SHARPNESS = 7.5; +const TWIST_INTENSITY = 2.2; +const TWIST_SPEED = 0.9; +const CORE_EXPONENT = 1.6; + +const paletteStops = [ + { stop: 0.0, color: hexToRgb('010005') }, + { stop: 0.2, color: hexToRgb('07204f') }, + { stop: 0.45, color: hexToRgb('124aa0') }, + { stop: 0.7, color: hexToRgb('36a5ff') }, + { stop: 0.87, color: hexToRgb('99e6ff') }, + { stop: 1.0, color: hexToRgb('f1fbff') }, +]; + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10); +const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10); +const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10); +const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10); + +if (!host) { + console.error('Usage: node wormhole-tunnel.js [port] [width] [height] [interval-ms]'); + process.exit(1); +} + +if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) { + console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.'); + process.exit(1); +} + +if (width <= 0 || height <= 0) { + console.error('Matrix dimensions must be positive integers.'); + process.exit(1); +} + +const socket = dgram.createSocket('udp4'); +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); +const frame = createFrame(width, height); +let timeSeconds = 0; +const frameTimeSeconds = intervalMs / 1000; + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +socket.on('error', (error) => { + console.error('Socket error:', error.message); +}); + +function generateFrame() { + timeSeconds += frameTimeSeconds; + + const cx = (width - 1) / 2; + const cy = (height - 1) / 2; + const radiusNorm = Math.hypot(cx, cy) || 1; + + for (let row = 0; row < height; ++row) { + for (let col = 0; col < width; ++col) { + const dx = col - cx; + const dy = row - cy; + const radius = Math.hypot(dx, dy) / radiusNorm; + const angle = Math.atan2(dy, dx); + + const radialPhase = radius * RING_DENSITY - timeSeconds * RING_SPEED; + const ring = Math.exp(-Math.pow(Math.sin(radialPhase * Math.PI), 2) * RING_SHARPNESS); + + const twist = Math.sin(angle * TWIST_INTENSITY + timeSeconds * TWIST_SPEED) * 0.35 + 0.65; + const depth = Math.pow(clamp(1 - radius, 0, 1), CORE_EXPONENT); + + const value = clamp(ring * 0.6 + depth * 0.3 + twist * 0.1, 0, 1); + frame[toIndex(col, row, width)] = samplePalette(paletteStops, value); + } + } + + return frameToPayload(frame); +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); +} + +setInterval(sendFrame, intervalMs); + +console.log( + `Streaming wormhole tunnel to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)` +); + -- 2.49.1 From be1a2f1e2119358935160590521f113256d843b3 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Fri, 3 Oct 2025 21:20:10 +0200 Subject: [PATCH 5/5] feat: add snek game pixel stream example --- test/pixelstream/snek/package-lock.json | 1319 +++++++++++++++++++++++ test/pixelstream/snek/package.json | 17 + test/pixelstream/snek/public/app.js | 274 +++++ test/pixelstream/snek/public/index.html | 59 + test/pixelstream/snek/server.js | 83 ++ 5 files changed, 1752 insertions(+) create mode 100644 test/pixelstream/snek/package-lock.json create mode 100644 test/pixelstream/snek/package.json create mode 100644 test/pixelstream/snek/public/app.js create mode 100644 test/pixelstream/snek/public/index.html create mode 100644 test/pixelstream/snek/server.js diff --git a/test/pixelstream/snek/package-lock.json b/test/pixelstream/snek/package-lock.json new file mode 100644 index 0000000..456abf6 --- /dev/null +++ b/test/pixelstream/snek/package-lock.json @@ -0,0 +1,1319 @@ +{ + "name": "snek", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "snek", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "express": "^4.21.2", + "ws": "^8.18.3" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "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", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "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", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + }, + "dependencies": { + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + } + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + } + }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "requires": { + "safe-buffer": "5.2.1" + } + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" + }, + "cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" + }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" + }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "requires": { + "es-errors": "^1.3.0" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "requires": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + } + }, + "finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + } + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "requires": { + "function-bind": "^1.1.2" + } + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + }, + "merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + }, + "object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "requires": { + "ee-first": "1.1.1" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, + "qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "requires": { + "side-channel": "^1.0.6" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "requires": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "dependencies": { + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, + "serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "requires": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + } + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + } + }, + "side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + } + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, + "ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "requires": {} + } + } +} diff --git a/test/pixelstream/snek/package.json b/test/pixelstream/snek/package.json new file mode 100644 index 0000000..4e6541f --- /dev/null +++ b/test/pixelstream/snek/package.json @@ -0,0 +1,17 @@ +{ + "name": "snek", + "version": "1.0.0", + "description": "", + "main": "server.js", + "scripts": { + "start": "node server.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "express": "^4.21.2", + "ws": "^8.18.3" + } +} diff --git a/test/pixelstream/snek/public/app.js b/test/pixelstream/snek/public/app.js new file mode 100644 index 0000000..27ba3f7 --- /dev/null +++ b/test/pixelstream/snek/public/app.js @@ -0,0 +1,274 @@ +(() => { + const GRID_SIZE = 16; + const TICK_MS = 120; // game speed + + const canvas = document.getElementById('board'); + const ctx = canvas.getContext('2d', { alpha: false }); + const scoreEl = document.getElementById('score'); + const wsDot = document.getElementById('ws-dot'); + const wsLabel = document.getElementById('ws-label'); + + // NeoPixel matrix transmission config + // Most WS2812(B) LEDs expect GRB color order. Many matrices are wired serpentine. + const MATRIX_WIDTH = canvas.width; + const MATRIX_HEIGHT = canvas.height; + const COLOR_ORDER = 'RGB'; // one of: RGB, GRB, BRG, BGR, RBG, GBR + const BRIGHTNESS = 1.0; // 0.0 .. 1.0 scalar + const SERPENTINE = true; // true if every other row is reversed + const FLIP_X = true; // set true if physical matrix is mirrored horizontally + const FLIP_Y = false; // set true if physical matrix is flipped vertically + + /** Game state */ + let snakeSegments = []; + let currentDirection = { x: 1, y: 0 }; + let pendingDirection = { x: 1, y: 0 }; + let appleCell = null; + let score = 0; + let isGameOver = false; + let sendingFrame = false; + let ws; + + function connectWebSocket() { + const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; + const url = `${protocol}://${location.host}/ws`; + ws = new WebSocket(url); + ws.binaryType = 'arraybuffer'; + + setWsStatus('connecting'); + + ws.addEventListener('open', () => setWsStatus('open')); + ws.addEventListener('close', () => setWsStatus('closed')); + ws.addEventListener('error', () => setWsStatus('error')); + } + + function clampToByte(value) { + if (value < 0) return 0; + if (value > 255) return 255; + return value | 0; + } + + function applyBrightness(value) { + // value is 0..255, BRIGHTNESS is 0..1 + return clampToByte(Math.round(value * BRIGHTNESS)); + } + + function mapXYToLinearIndex(x, y, width, height) { + // Apply optional flips to align with physical orientation + const mappedX = FLIP_X ? (width - 1 - x) : x; + const mappedY = FLIP_Y ? (height - 1 - y) : y; + + // Serpentine wiring reverses every other row (commonly odd rows) + const isOddRow = (mappedY % 2) === 1; + const columnInRow = (SERPENTINE && isOddRow) ? (width - 1 - mappedX) : mappedX; + return (mappedY * width) + columnInRow; + } + + function writePixelWithColorOrder(target, baseIndex, r, g, b) { + // target is a Uint8Array, baseIndex is pixelIndex * 3 + switch (COLOR_ORDER) { + case 'RGB': + target[baseIndex + 0] = r; + target[baseIndex + 1] = g; + target[baseIndex + 2] = b; + break; + case 'GRB': + target[baseIndex + 0] = g; + target[baseIndex + 1] = r; + target[baseIndex + 2] = b; + break; + case 'BRG': + target[baseIndex + 0] = b; + target[baseIndex + 1] = r; + target[baseIndex + 2] = g; + break; + case 'BGR': + target[baseIndex + 0] = b; + target[baseIndex + 1] = g; + target[baseIndex + 2] = r; + break; + case 'RBG': + target[baseIndex + 0] = r; + target[baseIndex + 1] = b; + target[baseIndex + 2] = g; + break; + case 'GBR': + target[baseIndex + 0] = g; + target[baseIndex + 1] = b; + target[baseIndex + 2] = r; + break; + default: + // Fallback to GRB if misconfigured + target[baseIndex + 0] = g; + target[baseIndex + 1] = r; + target[baseIndex + 2] = b; + break; + } + } + + function encodeCanvasToNeoPixelFrame() { + // Produces headerless frame: width*height pixels, 3 bytes per pixel in COLOR_ORDER + const width = MATRIX_WIDTH; + const height = MATRIX_HEIGHT; + const out = new Uint8Array(width * height * 3); + const src = ctx.getImageData(0, 0, width, height).data; // RGBA + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const srcIndex = ((y * width) + x) * 4; + const r = applyBrightness(src[srcIndex + 0]); + const g = applyBrightness(src[srcIndex + 1]); + const b = applyBrightness(src[srcIndex + 2]); + + const pixelIndex = mapXYToLinearIndex(x, y, width, height); + const base = pixelIndex * 3; + writePixelWithColorOrder(out, base, r, g, b); + } + } + + return out; + } + + function setWsStatus(state) { + if (state === 'open') { + wsDot.classList.add('ok'); + wsLabel.textContent = 'WS: connected'; + } else if (state === 'connecting') { + wsDot.classList.remove('ok'); + wsLabel.textContent = 'WS: connecting…'; + } else if (state === 'closed') { + wsDot.classList.remove('ok'); + wsLabel.textContent = 'WS: disconnected'; + // try to reconnect after a delay + setTimeout(connectWebSocket, 1000); + } else { + wsDot.classList.remove('ok'); + wsLabel.textContent = 'WS: error'; + } + } + + function resetGame() { + snakeSegments = [ { x: 4, y: 8 }, { x: 3, y: 8 }, { x: 2, y: 8 } ]; + currentDirection = { x: 1, y: 0 }; + pendingDirection = { x: 1, y: 0 }; + score = 0; + scoreEl.textContent = String(score); + isGameOver = false; + placeApple(); + } + + function placeApple() { + const occupied = new Set(snakeSegments.map(c => `${c.x},${c.y}`)); + let x, y; + do { + x = Math.floor(Math.random() * GRID_SIZE); + y = Math.floor(Math.random() * GRID_SIZE); + } while (occupied.has(`${x},${y}`)); + appleCell = { x, y }; + } + + function stepGame() { + if (isGameOver) return; + + // Commit pending direction (prevents double-turning in one tick) + if ((pendingDirection.x !== -currentDirection.x) || (pendingDirection.y !== -currentDirection.y)) { + currentDirection = pendingDirection; + } + + const head = snakeSegments[0]; + const newHead = { x: head.x + currentDirection.x, y: head.y + currentDirection.y }; + + // Wall collision + if (newHead.x < 0 || newHead.x >= GRID_SIZE || newHead.y < 0 || newHead.y >= GRID_SIZE) { + isGameOver = true; + return; + } + + // Self collision + for (let i = 0; i < snakeSegments.length; i++) { + const seg = snakeSegments[i]; + if (seg.x === newHead.x && seg.y === newHead.y) { + isGameOver = true; + return; + } + } + + // Move snake + snakeSegments.unshift(newHead); + const ateApple = (newHead.x === appleCell.x && newHead.y === appleCell.y); + if (ateApple) { + score += 1; + scoreEl.textContent = String(score); + placeApple(); + } else { + snakeSegments.pop(); + } + } + + function renderGame() { + // Background + ctx.fillStyle = '#000000'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Apple + ctx.fillStyle = '#d23'; + ctx.fillRect(appleCell.x, appleCell.y, 1, 1); + + // Snake + ctx.fillStyle = '#3bd16f'; + for (const cell of snakeSegments) { + ctx.fillRect(cell.x, cell.y, 1, 1); + } + + if (isGameOver) { + // Simple overlay pixel art for game over (draw a cross) + ctx.fillStyle = '#f33'; + for (let i = 0; i < GRID_SIZE; i++) { + ctx.fillRect(i, i, 1, 1); + ctx.fillRect(GRID_SIZE - 1 - i, i, 1, 1); + } + } + } + + function sendFrame() { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + if (sendingFrame) return; + sendingFrame = true; + try { + const frame = encodeCanvasToNeoPixelFrame(); + // Send ArrayBuffer view; receivers expect raw 3-byte-per-pixel stream + ws.send(frame.buffer); + } catch (_) { + // ignore + } finally { + sendingFrame = false; + } + } + + function gameLoop() { + stepGame(); + renderGame(); + sendFrame(); + } + + // Controls + window.addEventListener('keydown', (e) => { + if (e.key === ' ') { + resetGame(); + return; + } + if (e.key === 'ArrowUp' && currentDirection.y !== 1) { + pendingDirection = { x: 0, y: -1 }; + } else if (e.key === 'ArrowDown' && currentDirection.y !== -1) { + pendingDirection = { x: 0, y: 1 }; + } else if (e.key === 'ArrowLeft' && currentDirection.x !== 1) { + pendingDirection = { x: -1, y: 0 }; + } else if (e.key === 'ArrowRight' && currentDirection.x !== -1) { + pendingDirection = { x: 1, y: 0 }; + } + }); + + // Start + resetGame(); + connectWebSocket(); + setInterval(gameLoop, TICK_MS); +})(); diff --git a/test/pixelstream/snek/public/index.html b/test/pixelstream/snek/public/index.html new file mode 100644 index 0000000..9e1bbc5 --- /dev/null +++ b/test/pixelstream/snek/public/index.html @@ -0,0 +1,59 @@ + + + + + + Snek 16x16 + + + +

16×16 Snek

+
+
Score: 0
+
WS: connecting…
+
+ +
Arrow keys to move. Space to restart.
+ + + diff --git a/test/pixelstream/snek/server.js b/test/pixelstream/snek/server.js new file mode 100644 index 0000000..fe17b5e --- /dev/null +++ b/test/pixelstream/snek/server.js @@ -0,0 +1,83 @@ +const path = require('path'); +const http = require('http'); +const express = require('express'); +const dgram = require('dgram'); +const { WebSocketServer } = require('ws'); + +const HTTP_PORT = process.env.PORT || 3000; +// UDP settings +// Default broadcast; override with unicast address via UDP_ADDR if you have a single receiver +const UDP_BROADCAST_PORT = Number(process.env.UDP_PORT) || 4210; +const UDP_BROADCAST_ADDR = process.env.UDP_ADDR || '10.0.1.144'; + +// NeoPixel frame properties for basic validation/logging (no transformation here) +const MATRIX_WIDTH = Number(process.env.MATRIX_WIDTH) || 16; +const MATRIX_HEIGHT = Number(process.env.MATRIX_HEIGHT) || 16; +const BYTES_PER_PIXEL = 3; // GRB without alpha + +const app = express(); +app.use(express.static(path.join(__dirname, 'public'))); + +const server = http.createServer(app); +const wss = new WebSocketServer({ server, path: '/ws' }); + +const udpSocket = dgram.createSocket('udp4'); +udpSocket.on('error', (err) => { + console.error('[UDP] error:', err); +}); +udpSocket.bind(() => { + try { + udpSocket.setBroadcast(true); + console.log(`[UDP] Ready to broadcast on ${UDP_BROADCAST_ADDR}:${UDP_BROADCAST_PORT}`); + } catch (e) { + console.error('[UDP] setBroadcast failed:', e); + } +}); + +wss.on('connection', (ws, req) => { + const clientAddress = req?.socket?.remoteAddress || 'unknown'; + console.log(`[WS] Client connected: ${clientAddress}`); + + ws.on('message', (data) => { + const bufferToSend = Buffer.isBuffer(data) + ? data + : (data instanceof ArrayBuffer ? Buffer.from(data) : Buffer.from(String(data))); + + const expectedSize = MATRIX_WIDTH * MATRIX_HEIGHT * BYTES_PER_PIXEL; + if (bufferToSend.length !== expectedSize) { + console.warn(`[WS] Unexpected frame size: ${bufferToSend.length} bytes (expected ${expectedSize}).`); + } + + const hexPayload = bufferToSend.toString('hex'); + const udpPayload = Buffer.from(`RAW:${hexPayload}`, 'ascii'); + + udpSocket.send( + udpPayload, + UDP_BROADCAST_PORT, + UDP_BROADCAST_ADDR, + (err) => { + if (err) { + console.error('[UDP] send error:', err.message); + } + } + ); + }); + + ws.on('close', () => { + console.log('[WS] Client disconnected'); + }); + + ws.on('error', (err) => { + console.error('[WS] error:', err.message); + }); +}); + +server.listen(HTTP_PORT, () => { + console.log(`Server listening on http://localhost:${HTTP_PORT}`); +}); + +process.on('SIGINT', () => { + console.log('Shutting down...'); + try { udpSocket.close(); } catch {} + try { server.close(() => process.exit(0)); } catch {} +}); -- 2.49.1