diff --git a/config/reaper.ts b/config/reaper.ts index ab0d06b8e6b5c2616034bc9da104dd2d335962e2..10b7fb2a968af33542b35aa825cedbdc691f979e 100644 --- a/config/reaper.ts +++ b/config/reaper.ts @@ -1,72 +1,71 @@ import * as config from "#config"; +import { lucide, mdi, txt } from "@clo/creative-control/icons"; import { Reaper } from "@clo/creative-control/Reaper"; -import { SpeedEditor } from "@clo/creative-control/SpeedEditor"; -const cfg = config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) => { - const reaper = new Reaper(); +export default config.forApp( + "com.cockos.reaper", + ({ keypad, dialpad, mac }) => { + const reaper = new Reaper(); - // Sync state to LEDs - reaper.on("transport", (transport) => { - se.leds.cut = transport.recording; - se.leds.dissolve = transport.recording; - se.leds.smoothCut = transport.recording; - }); - mac.on("window", (window) => { - se.leds.videoOnly = Boolean(window?.title.startsWith("FX:")); - }); + const addInstrument = keypad.menu((menu) => { + menu.key("up-left", txt("KK"), () => { + mac.toast("Add Komplete Kontrol"); + reaper.runScript("insert_komplete_kontrol_track"); + keypad.back(); + }); + menu.key("up", txt("AD2"), () => { + mac.toast("Add Addictive Drums"); + reaper.runScript("insert_addictive_drums_track"); + keypad.back(); + }); + menu.key("up-right", txt("Blank"), () => { + mac.toast("Blank Track"); + reaper.runScript("insert_blank_track"); + keypad.back(); + }); + }); - // Keyboard Actions - SpeedEditor.camNumbersToNumpad(se, mac); - se.onPress("stopPlay", () => { - if (reaper.transport.recording) { - reaper.runAction("transport-stop-save-all-recorded-media"); - } else { + const recording = keypad.overlay((overlay) => { + overlay.key("down-left", lucide("Save").fg("green"), () => { + reaper.runAction("transport-stop-save-all-recorded-media"); + }); + overlay.key("down", lucide("Redo").fg("green"), () => { + reaper.runAction("transport-stop-save-all-recorded-media"); + reaper.runAction("transport-record"); + }); + overlay.key("down-right", lucide("Trash").fg("#ff6b6b"), () => { + reaper.runAction("transport-stop-delete-all-recorded-media"); + }); + }); + + keypad.key("up-left", mdi("metronome"), () => { + reaper.runAction("options-toggle-metronome"); + }); + keypad.key("up", lucide("Clock"), () => reaper.runAction("file-project-settings")); + keypad.key("up-right", txt("BPM"), () => { + reaper.runAction("tempo-increase-current-project-tempo-01-bpm"); + }); + keypad.key("left", lucide("Plus"), () => { + addInstrument.open(); + }); + keypad.key("down-left", lucide("Mic").fg("red"), () => { + reaper.runAction("transport-record"); + }); + keypad.key("down-right", lucide("Play"), () => { reaper.runAction("transport-play-stop"); - } - }); - se.onPress("smartInsert", () => { - mac.toast("Add Komplete Kontrol"); - reaper.runScript("insert_komplete_kontrol_track"); - }); - se.onPress("append", () => { - mac.toast("Add Addictive Drums"); - reaper.runScript("insert_addictive_drums_track"); - }); - se.onPress("rippleOverwrite", () => { - mac.toast("Blank Track"); - reaper.runScript("insert_blank_track"); - }); - se.onPress("cut", () => { - if (reaper.transport.recording) { - reaper.runAction("transport-stop-delete-all-recorded-media"); - reaper.runAction("transport-record"); - } else { - reaper.runAction("transport-record"); - } - }); - se.onPress("dissolve", () => { - reaper.runAction("transport-stop-delete-all-recorded-media"); - }); - se.onPress("smoothCut", () => { - if (reaper.transport.recording) { - reaper.runAction("transport-stop-save-all-recorded-media"); - reaper.runAction("transport-record"); - } - }); - se.onPress("videoOnly", () => { - if (mac.window?.title.startsWith("FX:") && mac.mainWindow) { - mac.focusMainWindow(); - return; - } - reaper.runAction("track-view-fx-chain-for-current-last-touched-track"); - }); - se.onPress("fullView", () => { - mac.pressKey("x"); - }); + }); - // TODO: stabilize / find a better use-case for the knob - // se.on("jog", ({ value = 0 }) => { - // reaper.scrub(value / 20); - // }); -}); -export default cfg; + reaper.on("transport", (transport) => { + recording.active = transport.recording; + }); + + dialpad.on("rotate", (delta) => { + console.log({delta}) + reaper.runAction( + delta > 0 + ? "tempo-increase-current-project-tempo-0-1-bpm" + : "tempo-decrease-current-project-tempo-0-1-bpm", + ); + }); + }, +); diff --git a/config/reaper/scripts/clover_feedback.lua b/config/reaper/scripts/clover_feedback.lua new file mode 100644 index 0000000000000000000000000000000000000000..32e218ba7f84baed9b2017ad6ca95586dfd5e3a8 --- /dev/null +++ b/config/reaper/scripts/clover_feedback.lua @@ -0,0 +1,54 @@ +-- Clover live feedback: continuously write the project's tempo and time +-- signature to state.json (next to this script) so the Clover Node process can +-- watch the file and update the keypad. Re-schedules itself via reaper.defer, +-- writing only when a value actually changes. +-- +-- REAPER's OSC has a tempo token but no time-signature feedback, so we read both +-- here in one place (reaper.TimeMap_GetTimeSigAtTime returns num, denom, tempo). + +-- Avoid stacking multiple defer loops if the script gets launched again (e.g. a +-- Clover restart while REAPER keeps running). +if reaper.GetExtState("clover", "feedback") == "1" then + return +end +reaper.SetExtState("clover", "feedback", "1", false) +reaper.atexit(function() + reaper.SetExtState("clover", "feedback", "", false) +end) + +local source = debug.getinfo(1, "S").source +local script_path = source:match("^@(.+)$") +local script_dir = script_path:match("^(.*)[/\\].-$") +local sep = package.config:sub(1, 1) +local state_path = script_dir .. sep .. "state.json" + +local last = nil + +local function snapshot() + local position + if reaper.GetPlayState() > 0 then + position = reaper.GetPlayPosition() + else + position = reaper.GetCursorPosition() + end + + local num, denom, tempo = reaper.TimeMap_GetTimeSigAtTime(0, position) + num = math.floor(num + 0.5) + denom = math.floor(denom + 0.5) + return string.format('{"tempo":%.3f,"timesig":"%d/%d"}', tempo, num, denom) +end + +local function poll() + local snap = snapshot() + if snap ~= last then + last = snap + local file = io.open(state_path, "w") + if file then + file:write(snap) + file:close() + end + end + reaper.defer(poll) +end + +poll() diff --git a/examples/dialpad.ts b/examples/dialpad.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdb87099700d5bdc7f1903a919e88d407ee33438 --- /dev/null +++ b/examples/dialpad.ts @@ -0,0 +1,14 @@ +// Listen to the dialpad. NOTE: until the OS-seize phase, turning the dial also +// scrolls macOS and the buttons act as mouse buttons. +import { Dialpad } from "../src/Dialpad.ts"; + +const dialpad = await Dialpad.open(); +console.info(dialpad.connected ? "Dialpad connected" : "Waiting for dialpad…"); + +dialpad.on("connect", () => console.info("connect")); +dialpad.on("disconnect", () => console.info("disconnect")); +dialpad.on("rotate", (delta) => console.info("rotate", delta)); +dialpad.on("spin", (delta) => console.info("spin", delta)); +dialpad.on("keydown", (button) => console.info("down", button)); +dialpad.on("keyup", (button) => console.info("up", button)); +dialpad.onPress("circle", () => console.info("circle pressed!")); diff --git a/examples/enumerate-hid.ts b/examples/enumerate-hid.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ece855995c7d8e4d164cf44a41c60485814cd09 --- /dev/null +++ b/examples/enumerate-hid.ts @@ -0,0 +1,43 @@ +// Phase 0: list HID devices so we can identify the MX Creative Console halves. +// Keypad is expected as Elgato (0x0fd9); the Bluetooth dialpad is likely +// Logitech (0x046d) speaking HID++. +const { devices } = await import("node-hid"); + +const VENDOR_NAMES: Record = { + 0x046d: "Logitech", + 0x0fd9: "Elgato", + 0x05ac: "Apple", +}; + +const hex = (n: number | undefined, width = 4) => "0x" + (n ?? 0).toString(16).padStart(width, "0"); + +const all = devices(); + +const format = (d: import("node-hid").Device) => + [ + `${hex(d.vendorId)}:${hex(d.productId)}`, + (VENDOR_NAMES[d.vendorId] ?? "?").padEnd(8), + `usage=${hex(d.usagePage)}/${hex(d.usage)}`, + `iface=${d.interface}`, + `| ${d.manufacturer ?? ""} ${d.product ?? ""}`.trim(), + d.path ? `\n path=${d.path}` : "", + ].join(" "); + +const interesting = all.filter( + (d) => d.vendorId === 0x046d || d.vendorId === 0x0fd9, +); + +console.info(`Total HID devices: ${all.length}`); +console.info(`\n=== Logitech (0x046d) + Elgato (0x0fd9) ===`); +if (interesting.length === 0) { + console.info(" (none found — dialpad may not surface as a HID device)"); +} else { + for (const d of interesting) console.info(" " + format(d)); +} + +console.info(`\n=== All vendors present ===`); +const byVendor = new Map(); +for (const d of all) byVendor.set(d.vendorId, (byVendor.get(d.vendorId) ?? 0) + 1); +for (const [vid, count] of [...byVendor].sort((a, b) => b[1] - a[1])) { + console.info(` ${hex(vid)} ${(VENDOR_NAMES[vid] ?? "").padEnd(8)} ${count}`); +} diff --git a/examples/event-listener.ts b/examples/event-listener.ts index fec0c4218388a490abd72679798b188e8665acbb..1d55fdbab0c1a1906dd910f34d256abbc9c073a7 100644 --- a/examples/event-listener.ts +++ b/examples/event-listener.ts @@ -15,7 +15,7 @@ editor.on("keypress", (key) => { } }); editor.onDoublePress("transition", () => { - console.info('(double press) "Title"'); + console.info("(double press) \"Title\""); }); console.info("init"); diff --git a/examples/face-preview.ts b/examples/face-preview.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbbbd80fa9ce1ba5f253ccec785e2ab320191082 --- /dev/null +++ b/examples/face-preview.ts @@ -0,0 +1,26 @@ +// Render key faces to PNGs (no hardware needed) so you can eyeball them before +// pushing to the device. Writes to /tmp/face-preview/. +import { mkdirSync, writeFileSync } from "node:fs"; +import sharp from "sharp"; +import { blank, lucide, mdi, txt } from "../src/icons.ts"; + +const faces: Record = { + blank: blank.svg, + metronome: mdi("metronome").svg, + "time-sig": lucide("Clock").svg, + bpm: txt("BPM").svg, + "add-instrument": lucide("Plus").svg, + record: lucide("Disc").fg("red").svg, + play: lucide("Play").svg, + save: lucide("Save").fg("green").svg, +}; + +const outDir = "/tmp/face-preview"; +mkdirSync(outDir, { recursive: true }); + +for (const [name, face] of Object.entries(faces)) { + const png = await sharp(Buffer.from(face)).resize(118, 118).png().toBuffer(); + const file = `${outDir}/${name}.png`; + writeFileSync(file, png); + console.info(`${name.padEnd(16)} ${png.length} bytes ${file}`); +} diff --git a/examples/hid-sniff.ts b/examples/hid-sniff.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e3dbe8450a62de87ce624bd804f59d5edc145a1 --- /dev/null +++ b/examples/hid-sniff.ts @@ -0,0 +1,67 @@ +// Phase 0 sniffer: open an MX Creative Console half by product-name match and +// log every HID input report. Operate the control you want to map and watch the +// report id + bytes. Logitech HID++ events arrive as report id 0x10 (short, 7B) +// or 0x11 (long, 20B). Default mouse/consumer reports use other ids. +// +// node examples/hid-sniff.ts [name-substring] [seconds] +// node examples/hid-sniff.ts dialpad 25 +const { devices, HID } = await import("node-hid"); + +const match = (process.argv[2] ?? "dialpad").toLowerCase(); +const durationSec = Number(process.argv[3] ?? 0); + +const hex = (n: number, w = 2) => n.toString(16).padStart(w, "0"); + +const dev = devices().find( + (d) => (d.product ?? "").toLowerCase().includes(match) && d.path, +); +if (!dev?.path) { + console.error( + `No HID device matching "${match}". Run examples/enumerate-hid.ts to list.`, + ); + process.exit(1); +} + +console.info( + `Opening ${dev.product} 0x${hex(dev.vendorId, 4)}:0x${hex(dev.productId, 4)}\n path=${dev.path}`, +); + +let device: import("node-hid").HID; +try { + device = new HID(dev.path); +} catch (error) { + console.error( + "Failed to open device. On macOS, grant the terminal/node Input Monitoring\n" + + "(System Settings > Privacy & Security > Input Monitoring), then retry.\n", + error, + ); + process.exit(1); +} + +const t0 = Date.now(); +let count = 0; +device.on("data", (buf: Buffer) => { + const bytes = [...buf]; + const id = bytes[0]; + const kind = id === 0x11 + ? "hid++ long " + : id === 0x10 + ? "hid++ short" + : "report "; + const ms = String(Date.now() - t0).padStart(6); + console.info( + `+${ms}ms ${kind} id=0x${hex(id)} ${bytes.map((b) => hex(b)).join(" ")}`, + ); + count += 1; +}); +device.on("error", (error) => console.error("device error:", error)); + +console.info("Listening — operate the dial / knob / buttons. Ctrl-C to stop.\n"); + +if (durationSec > 0) { + setTimeout(() => { + console.info(`\nCaptured ${count} reports in ${durationSec}s. Closing.`); + device.close(); + process.exit(0); + }, durationSec * 1000); +} diff --git a/examples/keypad-demo.ts b/examples/keypad-demo.ts new file mode 100644 index 0000000000000000000000000000000000000000..b790811df910e0b3eab32a01759ee7d1045ed991 --- /dev/null +++ b/examples/keypad-demo.ts @@ -0,0 +1,30 @@ +// Push the Reaper root layout to the physical keypad as one atomic panel write +// and exit (faces persist on the device). Validates the panel image protocol. +import { Keypad } from "../src/Keypad.ts"; +import { composePanel, type Face } from "../src/KeypadUI.ts"; +import { blank, lucide, txt } from "../src/icons.ts"; + +const keypad = await Keypad.open(); +if (!keypad.connected) { + console.error("Keypad not connected."); + process.exit(1); +} + +keypad.setBrightness(0.85); + +// Faces in grid order: up-left, up, up-right, left, center, right, down-*. +const faces: Face[] = [ + lucide("AlarmClock"), + lucide("Clock"), + txt("BPM"), + lucide("Plus"), + blank, + blank, + lucide("Disc").fg("red"), + blank, + lucide("Play"), +]; + +keypad.setPanel(await composePanel(faces)); +console.info("Pushed the panel to the keypad — look at the device."); +process.exit(0); diff --git a/package.json b/package.json index 0ed664cb5c254d52ed28404e29796ace9ddacf90..b27d95f6d282080a3d00989bbd8d8132129bf263 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,12 @@ }, "dependencies": { "@clo/lib": "jsr:^3.0.0", + "@mdi/svg": "^7.4.47", "@types/node": "^25.5.0", + "lucide-static": "^1.21.0", + "mdi-ts": "^1.0.3", "node-hid": "^3.3.0", + "sharp": "^0.35.2", "usb": "^2.17.0" }, "imports": { @@ -20,6 +24,11 @@ "exports": { "./Mac": "./src/Mac.ts", "./SpeedEditor": "./src/SpeedEditor.ts", + "./Keypad": "./src/Keypad.ts", + "./KeypadUI": "./src/KeypadUI.ts", + "./icons": "./src/icons.ts", + "./signals": "./src/signals.ts", + "./Dialpad": "./src/Dialpad.ts", "./Reaper": "./src/Reaper.ts", "./Reaper/actions": "./src/Reaper/actions.ts" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 156d732b26550bda5569d5f18dd8b87d9892c9a3..1f4858b7330531ca337f1d83f1f8b92e7902df7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,186 +1,438 @@ -lockfileVersion: "9.0" +lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false importers: + .: dependencies: - "@clo/lib": + '@clo/lib': specifier: jsr:^3.0.0 - version: "@jsr/clo__lib@3.0.0" - "@types/node": + version: '@jsr/clo__lib@3.0.0' + '@mdi/svg': + specifier: ^7.4.47 + version: 7.4.47 + '@types/node': specifier: ^25.5.0 version: 25.5.0 + lucide-static: + specifier: ^1.21.0 + version: 1.21.0 + mdi-ts: + specifier: ^1.0.3 + version: 1.0.3 node-hid: specifier: ^3.3.0 version: 3.3.0 + sharp: + specifier: ^0.35.2 + version: 0.35.2 usb: specifier: ^2.17.0 version: 2.17.0 packages: - "@jsr/clo__lib@3.0.0": - resolution: { - integrity: sha512-oseZwHCAcXNPbqnGZ37l7+wAoj6ikIXE1VM0s6eD6fz4DcgM030Slf0T7Lgtn7fIdas5hlfx4JF54TR+vo4THw==, - tarball: https://npm.jsr.io/~/11/@jsr/clo__lib/3.0.0.tgz, - } - - "@types/node@25.5.0": - resolution: { - integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==, - } - - "@types/w3c-web-usb@1.0.13": - resolution: { - integrity: sha512-N2nSl3Xsx8mRHZBvMSdNGtzMyeleTvtlEw+ujujgXalPqOjIA6UtrqcB6OzyUjkTbDm3J7P1RNK1lgoO7jxtsw==, - } + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jsr/clo__lib@3.0.0': + resolution: {integrity: sha512-oseZwHCAcXNPbqnGZ37l7+wAoj6ikIXE1VM0s6eD6fz4DcgM030Slf0T7Lgtn7fIdas5hlfx4JF54TR+vo4THw==, tarball: https://npm.jsr.io/~/11/@jsr/clo__lib/3.0.0.tgz} + + '@mdi/svg@7.4.47': + resolution: {integrity: sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw==} + + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + + '@types/w3c-web-usb@1.0.13': + resolution: {integrity: sha512-N2nSl3Xsx8mRHZBvMSdNGtzMyeleTvtlEw+ujujgXalPqOjIA6UtrqcB6OzyUjkTbDm3J7P1RNK1lgoO7jxtsw==} ansi-regex@5.0.1: - resolution: { - integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} ansi-styles@4.3.0: - resolution: { - integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} cliui@8.0.1: - resolution: { - integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} color-convert@2.0.1: - resolution: { - integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, - } - engines: { node: ">=7.0.0" } + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} color-name@1.1.4: - resolution: { - integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, - } + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} emoji-regex@8.0.0: - resolution: { - integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==, - } + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} escalade@3.2.0: - resolution: { - integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} get-caller-file@2.0.5: - resolution: { - integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, - } - engines: { node: 6.* || 8.* || >= 10.* } + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} is-fullwidth-code-point@3.0.0: - resolution: { - integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + linq@4.0.3: + resolution: {integrity: sha512-dP0w2ERJXfVUk6VmmAK+Tz/SxFHwyY7VM6Mrq4fnJmeQf9JNEYFH6qJfV6Qn0N91mfwz2GEE/4S+RDkmDNyUJw==} + + lucide-static@1.21.0: + resolution: {integrity: sha512-6248z2/4sEyKkYAPPUYxOPiB2RCfMmLdMHuoOhsTFnoD40ixAoHmTVhOPux8ADa1NTBmzpEKF7WNePm+Ms503Q==} + + mdi-ts@1.0.3: + resolution: {integrity: sha512-wtVNYoCkvyYuTJ8osV5af6jfsE5o+UT1sAnaPVjAZiIXHVFpP2l66JzdAdNClnLfCk7g5wu8cWHrr/9ru1V8vQ==} node-addon-api@3.2.1: - resolution: { - integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==, - } + resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} node-addon-api@8.6.0: - resolution: { - integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==, - } - engines: { node: ^18 || ^20 || >= 21 } + resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==} + engines: {node: ^18 || ^20 || >= 21} node-gyp-build@4.8.4: - resolution: { - integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==, - } + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true node-hid@3.3.0: - resolution: { - integrity: sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==, - } - engines: { node: ">=10.16" } + resolution: {integrity: sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==} + engines: {node: '>=10.16'} hasBin: true pkg-prebuilds@1.0.0: - resolution: { - integrity: sha512-D9wlkXZCmjxj2kBHTw3fGSyjoahr33breGBoJcoezpi7ouYS59DJVOHMZ+dgqacSrZiJo4qtkXxLQTE+BqXJmQ==, - } - engines: { node: ">= 14.15.0" } + resolution: {integrity: sha512-D9wlkXZCmjxj2kBHTw3fGSyjoahr33breGBoJcoezpi7ouYS59DJVOHMZ+dgqacSrZiJo4qtkXxLQTE+BqXJmQ==} + engines: {node: '>= 14.15.0'} hasBin: true require-directory@2.1.1: - resolution: { - integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} string-width@4.2.3: - resolution: { - integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} strip-ansi@6.0.1: - resolution: { - integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} undici-types@7.18.2: - resolution: { - integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==, - } + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} usb@2.17.0: - resolution: { - integrity: sha512-UuFgrlglgDn5ll6d5l7kl3nDb2Yx43qLUGcDq+7UNLZLtbNug0HZBb2Xodhgx2JZB1LqvU+dOGqLEeYUeZqsHg==, - } - engines: { node: ">=12.22.0 <13.0 || >=14.17.0" } + resolution: {integrity: sha512-UuFgrlglgDn5ll6d5l7kl3nDb2Yx43qLUGcDq+7UNLZLtbNug0HZBb2Xodhgx2JZB1LqvU+dOGqLEeYUeZqsHg==} + engines: {node: '>=12.22.0 <13.0 || >=14.17.0'} wrap-ansi@7.0.0: - resolution: { - integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} y18n@5.0.8: - resolution: { - integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} yargs-parser@21.1.1: - resolution: { - integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} yargs@17.7.2: - resolution: { - integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} snapshots: - "@jsr/clo__lib@3.0.0": {} - "@types/node@25.5.0": + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + + '@jsr/clo__lib@3.0.0': {} + + '@mdi/svg@7.4.47': {} + + '@types/node@25.5.0': dependencies: undici-types: 7.18.2 - "@types/w3c-web-usb@1.0.13": {} + '@types/w3c-web-usb@1.0.13': {} ansi-regex@5.0.1: {} @@ -200,6 +452,8 @@ snapshots: color-name@1.1.4: {} + detect-libc@2.1.2: {} + emoji-regex@8.0.0: {} escalade@3.2.0: {} @@ -208,6 +462,14 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + linq@4.0.3: {} + + lucide-static@1.21.0: {} + + mdi-ts@1.0.3: + dependencies: + linq: 4.0.3 + node-addon-api@3.2.1: {} node-addon-api@8.6.0: {} @@ -225,6 +487,40 @@ snapshots: require-directory@2.1.1: {} + semver@7.8.5: {} + + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -235,11 +531,14 @@ snapshots: dependencies: ansi-regex: 5.0.1 + tslib@2.8.1: + optional: true + undici-types@7.18.2: {} usb@2.17.0: dependencies: - "@types/w3c-web-usb": 1.0.13 + '@types/w3c-web-usb': 1.0.13 node-addon-api: 8.6.0 node-gyp-build: 4.8.4 diff --git a/readme.md b/readme.md index dfc7da09984ae980b1a13cfd781b2d5fc936f918..88272477348ac77c78074abaca9a75c3affbca25 100644 --- a/readme.md +++ b/readme.md @@ -15,17 +15,16 @@ other configurations. ## Hardware -TODO: +### DaVinci Resolve Speed Editor -- generic keyboard / karabiner elements integration? -- custom trackpad integration? +A $200 dual-hardware system. ### DaVinci Resolve Speed Editor A $300 bundle containing Fusion Studio and the control surface, it's a great deal. There is a large, high resolution knob, as well as many keys with some having lights. This repo includes an SDK to reprogram it to be useful in any -program. +program. **Note**: It is unused as of 2026-06-24. ![DaVinci Resolve Speed Editor](docs/speed-editor.jpg) diff --git a/readme.new.md b/readme.new.md new file mode 100644 index 0000000000000000000000000000000000000000..dd01b0e3ddd5342cfa494f47dbefdf1e011a19d7 --- /dev/null +++ b/readme.new.md @@ -0,0 +1,3 @@ +# Creative Toolkit + +Clover's **Creative Toolkit** is collection of macOS software that she use to create the projects seen on [paper clover](https://paperclover.net). \ No newline at end of file diff --git a/src/Dialpad.ts b/src/Dialpad.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b4ef3e86f1b7c4a3e701abb9196306c860115cd --- /dev/null +++ b/src/Dialpad.ts @@ -0,0 +1,208 @@ +import { Events } from "@clo/lib/Events.ts"; +import type { Dispose } from "@clo/lib/ts.ts"; + +// The MX Creative Console Dialpad pairs over Bluetooth as a Logitech HID++ +// device. It does NOT need HID++ feature access: it streams a single 8-byte +// input report (id 0x02) that decodes cleanly. Discovered by sniffing — see +// examples/hid-sniff.ts. +// +// 02 buttons -- -- -- -- spin rotate +// b1 b6 b7 +// +// `rotate` (main dial) and `spin` (knob) are signed int8 deltas; `buttons` is a +// bitmask. NOTE: until the OS-seize phase, macOS also consumes these reports +// (the dial scrolls, the buttons act as mouse buttons). +const LOGITECH_VENDOR_ID = 0x046d; +const DIALPAD_PRODUCT_ID = 0xbc00; + +const REPORT_ID = 0x02; +const BUTTON_BYTE = 1; +const SPIN_BYTE = 6; +const ROTATE_BYTE = 7; + +const buttonIds = ["circle", "triangle", "square", "cross"] as const; + +const BUTTON_BIT_BY_ID = new Map([ + ["square", 0x08], + ["cross", 0x10], + ["circle", 0x20], + ["triangle", 0x40], +]); + +const RECONNECT_INTERVAL_MS = 1000; + +/** + * Node.js bindings for the Logitech MX Creative Console Dialpad (Bluetooth). + */ +export class Dialpad extends Events { + static buttons = buttonIds; + + #options: Required; + #device: import("node-hid").HID | null = null; + #closed = false; + #ready = false; + #reconnectTimer: ReturnType | null = null; + #activeButtons = new Set(); + #lastButtonMask = 0; + + private constructor(options: Dialpad.Options = {}) { + super(); + this.#options = { + vendorId: options.vendorId ?? LOGITECH_VENDOR_ID, + productId: options.productId ?? DIALPAD_PRODUCT_ID, + path: options.path ?? null, + }; + } + + static async open(options: Dialpad.Options = {}) { + const dialpad = new Dialpad(options); + await dialpad.#start(); + return dialpad; + } + + get connected(): boolean { + return this.#ready; + } + + onPress(button: Dialpad.Button, listener: () => void): Dispose { + return this.on("keypress", (code) => { + if (button === code) listener(); + }); + } + + close() { + if (this.#closed) return; + this.#closed = true; + if (this.#reconnectTimer) clearInterval(this.#reconnectTimer); + this.#reconnectTimer = null; + this.#disconnect(false); + this.emit("close"); + } + + async #start() { + await this.#connect(); + // Bluetooth devices don't raise `usb` hotplug events, so poll instead. + this.#reconnectTimer = setInterval(() => { + if (!this.#device && !this.#closed) void this.#connect(); + }, RECONNECT_INTERVAL_MS); + this.#reconnectTimer.unref?.(); + } + + async #connect() { + if (this.#device || this.#closed) return; + + const { devices, HID } = await import("node-hid"); + const match = devices().find( + (device) => + device.vendorId === this.#options.vendorId + && device.productId === this.#options.productId + && (this.#options.path ? device.path === this.#options.path : true) + && Boolean(device.path), + ); + if (!match?.path) return; + + try { + const device = new HID(match.path); + this.#device = device; + device.on("data", (report) => { + if (this.#device === device) this.#handleReport(report); + }); + device.on("error", (error) => { + if (this.#device === device) this.#handleDeviceError(error); + }); + this.#ready = true; + this.emit("connect"); + } catch { + this.#device = null; + // Will retry on the next poll tick. + } + } + + #handleReport(report: Buffer | number[]) { + const bytes = Uint8Array.from(report); + if (bytes[0] !== REPORT_ID) return; + + const rotate = toInt8(bytes[ROTATE_BYTE] ?? 0); + if (rotate !== 0) this.emit("rotate", rotate); + + const spin = toInt8(bytes[SPIN_BYTE] ?? 0); + if (spin !== 0) this.emit("spin", spin); + + const mask = bytes[BUTTON_BYTE] ?? 0; + if (mask !== this.#lastButtonMask) { + this.#lastButtonMask = mask; + this.#applyButtonState(mask); + } + } + + #applyButtonState(mask: number) { + const next = new Set(); + for (const [button, bit] of BUTTON_BIT_BY_ID) { + if (mask & bit) next.add(button); + } + + for (const button of this.#activeButtons) { + if (!next.has(button)) this.emit("keyup", button); + } + for (const button of next) { + if (!this.#activeButtons.has(button)) { + this.emit("keydown", button); + this.emit("keypress", button); + } + } + + this.#activeButtons = next; + this.emit("key", [...next]); + } + + #handleDeviceError(_error: unknown) { + this.#disconnect(true); + } + + #disconnect(emitEvent: boolean) { + const device = this.#device; + this.#device = null; + this.#ready = false; + this.#activeButtons.clear(); + this.#lastButtonMask = 0; + if (device) { + device.removeAllListeners("data"); + device.removeAllListeners("error"); + try { + device.close(); + } catch { + // Ignore close races when the device disappears mid-reconnect. + } + } + if (emitEvent) this.emit("disconnect"); + } +} + +function toInt8(byte: number): number { + return byte > 127 ? byte - 256 : byte; +} + +export declare namespace Dialpad { + export type Button = typeof buttonIds[number]; + + export interface Options { + vendorId?: number; + productId?: number; + path?: string | null; + } + + export type EventMap = { + "connect": []; + "disconnect": []; + "close": []; + "error": [error: unknown]; + /** Main dial delta (signed, clockwise positive). */ + "rotate": [delta: number]; + /** Up/down knob delta (signed, up positive). */ + "spin": [delta: number]; + "key": [activeButtons: ReadonlyArray