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.
260 lines
8.8 KiB
JavaScript
260 lines
8.8 KiB
JavaScript
// 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 };
|
|
})();
|