From d39f86d9c515503f312ae93df3c48a9e25869fe0 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 21 May 2026 00:19:51 -0400 Subject: [PATCH] =?UTF-8?q?ui:=20add=20bmd-card.js=20=E2=80=94=20visual=20?= =?UTF-8?q?DeckLink=20port=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders an inline SVG of the detected card (Duo 2 / Quad 2 / Mini Recorder / Mini Monitor / UltraStudio 4K Mini, with a generic fallback) showing each connector in its real physical position. Click to select. Used by recorders.html for SDI source selection. --- services/web-ui/public/js/bmd-card.js | 260 ++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 services/web-ui/public/js/bmd-card.js diff --git a/services/web-ui/public/js/bmd-card.js b/services/web-ui/public/js/bmd-card.js new file mode 100644 index 0000000..1256dce --- /dev/null +++ b/services/web-ui/public/js/bmd-card.js @@ -0,0 +1,260 @@ +// Wild Dragon — Blackmagic DeckLink port diagram. +// +// Renders an inline SVG that mirrors the real card's physical layout so +// operators can pick a port visually instead of guessing what "BM1" maps to. +// +// Usage: +// const svg = BMDCards.render({ +// model: 'DeckLink Duo 2', +// deviceCount: 4, +// selectedIndex: 0, +// onSelect: (i) => console.log('port', i), +// }); +// container.appendChild(svg); +// +// Add new cards by appending to MODELS. Coordinates are in SVG user units — +// each card is drawn with the bracket on the left (matches a PC case fitting). + +window.BMDCards = (function () { + const NS = 'http://www.w3.org/2000/svg'; + + // Each model entry describes the physical layout. `portPositions` is an + // ordered list — port 0 is the first entry, matching ffmpeg's -i device index. + const MODELS = { + 'DeckLink Duo 2': { + friendly: 'DeckLink Duo 2', + form: 'PCIe half-height', + portKind: 'BNC SDI', + bidirectional: true, + width: 360, height: 180, + bracket: { x: 8, y: 14, w: 40, h: 152 }, + portPositions: [ + { cx: 28, cy: 36, label: '1', role: 'SDI 1' }, + { cx: 28, cy: 72, label: '2', role: 'SDI 2' }, + { cx: 28, cy: 108, label: '3', role: 'SDI 3' }, + { cx: 28, cy: 144, label: '4', role: 'SDI 4' }, + ], + notes: 'Each BNC is independently configurable as In or Out via Blackmagic Desktop Video Setup.', + }, + 'DeckLink Quad 2': { + friendly: 'DeckLink Quad 2', + form: 'PCIe full-height', + portKind: 'BNC SDI', + bidirectional: true, + width: 360, height: 300, + bracket: { x: 8, y: 14, w: 40, h: 272 }, + portPositions: [ + { cx: 28, cy: 36, label: '1', role: 'SDI 1' }, + { cx: 28, cy: 70, label: '2', role: 'SDI 2' }, + { cx: 28, cy: 104, label: '3', role: 'SDI 3' }, + { cx: 28, cy: 138, label: '4', role: 'SDI 4' }, + { cx: 28, cy: 172, label: '5', role: 'SDI 5' }, + { cx: 28, cy: 206, label: '6', role: 'SDI 6' }, + { cx: 28, cy: 240, label: '7', role: 'SDI 7' }, + { cx: 28, cy: 274, label: '8', role: 'SDI 8' }, + ], + notes: '8 reconfigurable BNCs. Pair adjacent ports for 6G/12G-SDI dual-link.', + }, + 'DeckLink Mini Recorder 4K': { + friendly: 'DeckLink Mini Recorder 4K', + form: 'PCIe half-height', + portKind: 'BNC SDI + HDMI', + bidirectional: false, + width: 360, height: 180, + bracket: { x: 8, y: 14, w: 40, h: 152 }, + portPositions: [ + { cx: 28, cy: 50, label: 'SDI', role: 'SDI In', kind: 'sdi' }, + { cx: 28, cy: 130, label: 'HDMI', role: 'HDMI In', kind: 'hdmi' }, + ], + notes: 'Input-only. SDI and HDMI share a single capture channel — pick which input ffmpeg should open.', + }, + 'DeckLink Mini Monitor 4K': { + friendly: 'DeckLink Mini Monitor 4K', + form: 'PCIe half-height', + portKind: 'BNC SDI + HDMI', + bidirectional: false, + width: 360, height: 180, + bracket: { x: 8, y: 14, w: 40, h: 152 }, + portPositions: [ + { cx: 28, cy: 50, label: 'SDI', role: 'SDI Out', kind: 'sdi' }, + { cx: 28, cy: 130, label: 'HDMI', role: 'HDMI Out', kind: 'hdmi' }, + ], + notes: 'Output-only — for playback monitoring, not capture.', + }, + 'UltraStudio 4K Mini': { + friendly: 'UltraStudio 4K Mini', + form: 'Thunderbolt 3', + portKind: 'BNC SDI + HDMI', + bidirectional: true, + width: 360, height: 220, + bracket: { x: 8, y: 14, w: 40, h: 192 }, + portPositions: [ + { cx: 28, cy: 44, label: '1', role: 'SDI 1' }, + { cx: 28, cy: 84, label: '2', role: 'SDI 2' }, + { cx: 28, cy: 124, label: '3', role: 'SDI 3' }, + { cx: 28, cy: 164, label: '4', role: 'SDI 4' }, + ], + notes: '4 reconfigurable BNCs + HDMI on the rear.', + }, + }; + + function el(tag, attrs) { + const node = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v !== undefined && v !== null) node.setAttribute(k, String(v)); + } + return node; + } + + function resolveModel(name) { + if (!name) return null; + const n = String(name).toLowerCase().trim(); + for (const [k, v] of Object.entries(MODELS)) { + if (k.toLowerCase() === n) return { ...v, name: k }; + } + // Loose match — accept things like "Blackmagic DeckLink Duo 2 (1)" or + // "decklinkduo2" by stripping non-alphanumerics. + const clean = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); + const target = clean(n); + for (const [k, v] of Object.entries(MODELS)) { + const ck = clean(k); + if (target.includes(ck) || ck.includes(target)) return { ...v, name: k }; + } + return null; + } + + function genericModel(portCount) { + const ports = Math.max(1, portCount || 1); + const h = 30 + ports * 36; + return { + friendly: 'Generic DeckLink', + form: 'unknown', + portKind: 'SDI', + bidirectional: false, + width: 360, height: h, + bracket: { x: 8, y: 14, w: 40, h: h - 28 }, + portPositions: Array.from({ length: ports }, (_, i) => ({ + cx: 28, cy: 36 + i * 36, label: String(i + 1), role: `Port ${i + 1}`, + })), + notes: 'Card model not recognised — pick the port by its system index.', + }; + } + + /** + * Render the SVG card. + * + * Options: + * model model name (string) + * deviceCount number of detected ports — used to size the generic fallback + * selectedIndex currently-selected port index + * onSelect (index) => void + * compact drop the model label + footer (default false) + */ + function render({ model, deviceCount, selectedIndex, onSelect, compact }) { + const m = resolveModel(model) || genericModel(deviceCount || 2); + + // If we resolved a model but the cluster reports more ports than we + // know about, fall back to a generic with the reported count so we + // don't hide ports. + const actualPorts = deviceCount || m.portPositions.length; + const positions = actualPorts > m.portPositions.length + ? genericModel(actualPorts).portPositions + : m.portPositions.slice(0, actualPorts); + + const svg = el('svg', { + viewBox: `0 0 ${m.width} ${m.height}`, + class: 'bmd-card-svg', + role: 'img', + 'aria-label': `${m.friendly} — ${actualPorts} ports`, + }); + + // PCB body (right of bracket) + svg.appendChild(el('rect', { + x: m.bracket.x + m.bracket.w + 6, + y: 10, + width: m.width - m.bracket.x - m.bracket.w - 18, + height: m.height - 20, + rx: 8, + class: 'bmd-card-body', + })); + + // Decorative trace lines on the PCB + const traceCount = Math.min(positions.length, 6); + for (let i = 0; i < traceCount; i++) { + const y = 24 + i * ((m.height - 48) / traceCount); + svg.appendChild(el('path', { + d: `M ${m.bracket.x + m.bracket.w + 16} ${y} L ${m.width - 20} ${y}`, + class: 'bmd-card-trace', + })); + } + + // Bracket (metal slot fitting) + svg.appendChild(el('rect', { + x: m.bracket.x, y: m.bracket.y, + width: m.bracket.w, height: m.bracket.h, + rx: 4, + class: 'bmd-card-bracket', + })); + + // Ports + positions.forEach((p, i) => { + const isSel = i === selectedIndex; + const g = el('g', { + class: 'bmd-port-group' + (isSel ? ' is-selected' : ''), + 'data-port': i, + }); + if (typeof onSelect === 'function') { + g.style.cursor = 'pointer'; + g.addEventListener('click', () => onSelect(i)); + } + + // BNC outer ring — bigger flange for SDI, smaller for HDMI + const isHdmi = p.kind === 'hdmi'; + g.appendChild(el(isHdmi ? 'rect' : 'circle', isHdmi + ? { x: p.cx - 14, y: p.cy - 6, width: 28, height: 12, rx: 1.5, class: 'bmd-port-ring' } + : { cx: p.cx, cy: p.cy, r: 12, class: 'bmd-port-ring' } + )); + + // Inner pin + g.appendChild(el('circle', { + cx: p.cx, cy: p.cy, r: isHdmi ? 1.5 : 4, + class: 'bmd-port-pin', + })); + + // Port label to the right of the connector + g.appendChild(el('text', { + x: p.cx + 24, y: p.cy - 2, + class: 'bmd-port-label', + })).textContent = p.label; + g.appendChild(el('text', { + x: p.cx + 24, y: p.cy + 11, + class: 'bmd-port-sublabel', + })).textContent = p.role; + + svg.appendChild(g); + }); + + if (!compact) { + // Model strip across the bottom of the PCB + svg.appendChild(el('text', { + x: m.bracket.x + m.bracket.w + 18, + y: m.height - 14, + class: 'bmd-card-model', + })).textContent = m.friendly.toUpperCase(); + } + + svg.dataset.bmdModel = m.name || 'generic'; + svg.dataset.portCount = String(actualPorts); + return svg; + } + + function getModelInfo(name) { + return resolveModel(name); + } + + function listModels() { + return Object.keys(MODELS); + } + + return { render, resolveModel, getModelInfo, listModels, MODELS }; +})();