ui: add bmd-card.js — visual DeckLink port picker

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.
This commit is contained in:
Zac Gaetano 2026-05-21 00:19:51 -04:00
parent f4a83eedc4
commit d39f86d9c5

View file

@ -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 };
})();