Simplify UI and add batch build endpoint

This commit is contained in:
klas
2026-02-10 09:58:11 +01:00
commit dee991a51a
83 changed files with 7043 additions and 0 deletions

400
scripts/build_mapping.js Normal file
View File

@@ -0,0 +1,400 @@
const fs = require('fs');
const mapping = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const rows = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
const templateRules = JSON.parse(fs.readFileSync(process.argv[4], 'utf8'));
const isTelemetryTemplate = (rule) => {
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
return (
filters.includes('.VideoInput') &&
!filters.includes('.Position') &&
!filters.includes('.VideoOutput') &&
fields.includes('VideoInput') &&
!fields.includes('Position')
);
};
const deriveActionName = (inputCaption) => {
if (!inputCaption) return '';
let name = inputCaption.trim();
name = name.replace(/^gevi[\s_]+/i, '');
name = name.replace(/[_\s]\d+$/i, '');
return name.trim();
};
const normalize = (value) => (value || '').trim().toLowerCase();
const normalizeInputName = (value) => (value || '').trim().toLowerCase().replace(/\s+/g, ' ');
const findTemplate = (kind) => {
if (kind === 'telemetry') {
const preferred = mapping.rules.find((rule) => {
if (!isTelemetryTemplate(rule)) return false;
const name = String(rule.input?.name || '');
return /^GeVi\s+(Pan|Tilt|Zoom|Focus)/i.test(name);
});
if (preferred) return preferred;
return (
mapping.rules.find((rule) => isTelemetryTemplate(rule)) ||
(templateRules.telemetry || []).find((rule) => isTelemetryTemplate(rule)) ||
null
);
}
return (
mapping.rules.find((rule) => {
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
return (
filters.includes('.SwitchMode') &&
filters.includes('.VideoInput') &&
filters.includes('.VideoOutput') &&
fields.includes('SwitchMode') &&
fields.includes('VideoOutput')
);
}) ||
(templateRules.crossswitch || []).find((rule) => {
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
return (
filters.includes('.SwitchMode') &&
filters.includes('.VideoInput') &&
filters.includes('.VideoOutput') &&
fields.includes('SwitchMode') &&
fields.includes('VideoOutput')
);
}) ||
null
);
};
const defaultPosFallback =
(templateRules.defaultpos || []).find((rule) => {
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
return filters.includes('.Position') || fields.includes('Position');
}) || null;
const fallbackTemplate = mapping.rules[0];
const telemetryTemplate = findTemplate('telemetry') || fallbackTemplate;
const crossSwitchTemplate = findTemplate('crossswitch') || fallbackTemplate;
const defaultPosTemplate =
mapping.rules.find((rule) => {
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
return filters.includes('.Position') || fields.includes('Position');
}) ||
defaultPosFallback ||
fallbackTemplate;
const findTelemetryActionTemplate = (actionName) => {
if (!actionName) return null;
const needle = actionName.toLowerCase();
return (
mapping.rules.find((rule) => {
const name = String(rule.input?.name || '').toLowerCase();
if (!name.startsWith('gevi ')) return false;
if (!name.includes(needle)) return false;
if (name.includes('prepos')) return false;
return isTelemetryTemplate(rule);
}) ||
(templateRules.telemetry || []).find((rule) => {
const name = String(rule.input?.name || '').toLowerCase();
if (!name.startsWith('gevi ')) return false;
if (!name.includes(needle)) return false;
if (name.includes('prepos')) return false;
return isTelemetryTemplate(rule);
}) ||
null
);
};
const findDefaultPosTemplate = (actionName) => {
if (!actionName) return null;
const needle = actionName.toLowerCase();
return (
mapping.rules.find((rule) => {
const name = String(rule.input?.name || '').toLowerCase();
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
if (!name.startsWith('gevi ')) return false;
if (!name.includes(needle)) return false;
return filters.includes('.Position') || fields.includes('Position');
}) ||
(templateRules.defaultpos || []).find((rule) => {
const name = String(rule.input?.name || '').toLowerCase();
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
if (!name.startsWith('gevi ')) return false;
if (!name.includes(needle)) return false;
return filters.includes('.Position') || fields.includes('Position');
}) ||
null
);
};
const outputKey = (inputName, o) => [normalize(inputName), normalize(o.actionField), normalize(o.action), normalize(o.server)].join('|');
const ruleIndexByName = new Map();
const ruleIndexByCamera = new Map();
const ruleIndexByAction = new Map();
const existingKeys = new Set();
const flagTemplates = new Map();
const fieldFlagCounts = new Map();
mapping.rules.forEach((rule, idx) => {
const normalized = normalizeInputName(rule.input.name);
ruleIndexByName.set(normalized, idx);
const match = normalized.match(/(\d+)$/);
if (match) {
const cam = match[1];
if (!ruleIndexByCamera.has(cam)) ruleIndexByCamera.set(cam, []);
ruleIndexByCamera.get(cam).push(idx);
}
for (const out of rule.outputs || []) {
existingKeys.add(outputKey(rule.input.name, out));
const key = [normalize(out.actionField), normalize(out.action)].join('|');
if (!flagTemplates.has(key)) {
flagTemplates.set(key, { ...out.flags });
}
const fieldKey = normalize(out.actionField);
const flagPair = `${out.flags.primary}|${out.flags.secondary}`;
if (!fieldFlagCounts.has(fieldKey)) fieldFlagCounts.set(fieldKey, new Map());
const counts = fieldFlagCounts.get(fieldKey);
counts.set(flagPair, (counts.get(flagPair) || 0) + 1);
}
});
const fallbackFlags = (actionField) => {
const counts = fieldFlagCounts.get(normalize(actionField));
if (!counts) return { primary: 0, secondary: 0 };
let best = '';
let bestCount = -1;
counts.forEach((count, key) => {
if (count > bestCount) {
bestCount = count;
best = key;
}
});
const [primary, secondary] = best.split('|').map((val) => Number(val));
return { primary: primary || 0, secondary: secondary || 0 };
};
let maxRuleId = Math.max(0, ...mapping.rules.map((r) => Number(r.id || 0)));
const isCrossSwitchAction = (actionName, category) => /crossswitch/i.test(actionName) || /crossbar/i.test(category);
const DEFAULTPOS_ACTIONS = {
MoveToDefaultPosition: 'DefaultPosCallUp',
ClearDefaultPosition: 'DefaultPosClear',
SaveDefaultPosition: 'DefaultPosSave',
};
const normalizeOutputAction = (actionName) => DEFAULTPOS_ACTIONS[actionName] || actionName;
const isDefaultPosAction = (actionName) => Object.prototype.hasOwnProperty.call(DEFAULTPOS_ACTIONS, actionName);
const isFocusSpeedAction = (actionName) => /focusnear|focusfar/i.test(actionName);
const isFocusInputAction = (actionName) => /focusnear|focusfar|focusstop|focus/i.test(actionName);
const parseSpeed = (value) => {
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
const buildAction = (actionName, cameraId, speed) => {
if (!actionName) return '';
const params = [];
if (cameraId) params.push('Comment: ""');
if (cameraId) params.push(`Camera: ${cameraId}`);
if (speed !== undefined) params.push(`Speed: ${speed}`);
if (params.length) return `@ ${actionName} (${params.join(', ')})`;
return `@ ${actionName} ()`;
};
for (const row of rows) {
const output1 = row.outputs.find((o) => o.kind === 'output1');
const inputName = row.caption || `Camera ${row.cameraId}`;
const inputCategoryRaw = String(output1?.category ?? '');
const inputActionRaw = String(output1?.action ?? '').trim();
const inputActionName = inputActionRaw || deriveActionName(inputName);
const actionName = inputActionName;
const inputCategory = inputCategoryRaw.toLowerCase();
const cameraId = String(row.cameraId ?? '').trim();
const outputs = [];
const seenOutputs = new Set();
const serverType = String(row.serverType ?? '').toLowerCase();
const isGscTarget = serverType.includes('geviscope') || serverType.includes('gsc');
const isGcoreTarget = serverType.includes('g-core') || serverType.includes('gcore');
const prefer = isGscTarget ? 'gsc' : isGcoreTarget ? 'gcore' : 'both';
const gscOut = prefer !== 'gcore' ? row.outputs.find((o) => o.kind === 'gsc') : null;
if (gscOut) {
const gscActionRaw = String(gscOut.action ?? actionName).trim();
const gscActionName = normalizeOutputAction(gscActionRaw);
const gscCamera = String(gscOut.ptzHead ?? '').trim();
const gscSpeed = parseSpeed(gscOut.speed);
const gscSpeedValue = isFocusSpeedAction(gscActionName) ? gscSpeed : undefined;
const gscCategory = String(gscOut.category ?? '').toLowerCase();
const crossSwitch = isCrossSwitchAction(gscActionName, inputCategory) || /viewer/.test(gscCategory);
const gscCaption = gscOut.caption || `GSC ${gscActionName}${cameraId ? `_${cameraId}` : ''}`;
const gscServer = gscOut.server || (String(row.serverType ?? '').toLowerCase().includes('geviscope') ? row.server : '');
if (actionName || gscOut.caption || gscOut.category || gscOut.server || String(row.serverType ?? '').toLowerCase().includes('geviscope')) {
const gscAction = crossSwitch ? '@ ViewerConnectLive ()' : buildAction(gscActionName, gscCamera, gscSpeedValue);
const gscFlags = flagTemplates.get([normalize('GscAction'), normalize(gscAction)].join('|')) || fallbackFlags('GscAction');
const out = {
id: '1',
name: gscCaption,
flags: gscFlags,
actionField: 'GscAction',
serverField: 'GscServer',
action: gscAction,
server: gscServer || '',
};
const key = outputKey(inputName, out);
if (!seenOutputs.has(key) && !existingKeys.has(key)) {
outputs.push(out);
seenOutputs.add(key);
existingKeys.add(key);
}
}
}
const gcoreOut = prefer !== 'gsc' ? row.outputs.find((o) => o.kind === 'gcore') : null;
if (gcoreOut) {
const gcoreActionRaw = String(gcoreOut.action ?? actionName).trim();
const gcoreActionName = normalizeOutputAction(gcoreActionRaw);
const gcoreCamera = String(gcoreOut.ptzHead ?? '').trim();
const gcoreSpeed = parseSpeed(gcoreOut.speed);
const gcoreSpeedValue = isFocusSpeedAction(gcoreActionName) ? gcoreSpeed : undefined;
const gcoreCategory = String(gcoreOut.category ?? '').toLowerCase();
const crossSwitch = isCrossSwitchAction(gcoreActionName, inputCategory) || /viewer/.test(gcoreCategory);
const gcoreCaption = gcoreOut.caption || `GNG ${gcoreActionName}${cameraId ? `_${cameraId}` : ''}`;
const gcoreServer = gcoreOut.server || (String(row.serverType ?? '').toLowerCase().includes('g-core') ? row.server : '');
if (actionName || gcoreOut.caption || gcoreOut.category || gcoreOut.server || String(row.serverType ?? '').toLowerCase().includes('g-core')) {
const gcoreAction = crossSwitch ? '@ ViewerConnectLive (Comment: "")' : buildAction(gcoreActionName, gcoreCamera, gcoreSpeedValue);
const gcoreFlags = flagTemplates.get([normalize('GCoreAction'), normalize(gcoreAction)].join('|')) || fallbackFlags('GCoreAction');
const out = {
id: '1',
name: gcoreCaption,
flags: gcoreFlags,
actionField: 'GCoreAction',
serverField: 'GCoreServer',
action: gcoreAction,
server: gcoreServer || '',
};
const key = outputKey(inputName, out);
if (!seenOutputs.has(key) && !existingKeys.has(key)) {
outputs.push(out);
seenOutputs.add(key);
existingKeys.add(key);
}
}
}
const kind = isCrossSwitchAction(actionName, inputCategory)
? 'crossswitch'
: isDefaultPosAction(actionName)
? 'defaultpos'
: 'telemetry';
let chosenTemplate = null;
if (kind === 'crossswitch') {
chosenTemplate = crossSwitchTemplate;
} else if (kind === 'defaultpos') {
chosenTemplate = findDefaultPosTemplate(normalizeOutputAction(actionName) || actionName) || defaultPosTemplate;
} else {
chosenTemplate = findTelemetryActionTemplate(actionName) || telemetryTemplate;
}
const nextFilters = chosenTemplate?.filters
? chosenTemplate.filters.map((f) => ({ ...f }))
: [];
const baseFilters = nextFilters;
const isTelemetry = kind === 'telemetry';
const nextFilterSet = isTelemetry
? baseFilters.map((f) => (f.name === '.VideoInput' ? { ...f, value: true } : f))
: baseFilters;
const baseFields = chosenTemplate?.fields ? [...chosenTemplate.fields] : [];
const nextFields = isTelemetry
? [...baseFields.filter((f) => f.name !== 'Position' && f.name !== 'VideoOutput'), ...(baseFields.some((f) => f.name === 'VideoInput') ? [] : [{ name: 'VideoInput', type: 'int32', value: 0 }])]
: baseFields;
const cleanedNextFields = isFocusInputAction(actionName) ? nextFields.filter((f) => f.name !== 'Temp') : nextFields;
const actionKey = actionName && cameraId ? `action:${normalize(actionName)}|cam:${cameraId}` : null;
let existingIdx = (actionKey ? ruleIndexByAction.get(actionKey) : undefined) ?? ruleIndexByName.get(normalizeInputName(inputName));
if (existingIdx === undefined && actionName && cameraId) {
const candidates = ruleIndexByCamera.get(cameraId) || [];
const actionNeedle = normalize(actionName);
for (const idx of candidates) {
const ruleName = normalizeInputName(mapping.rules[idx].input.name);
if (ruleName.includes(actionNeedle) && ruleName.endsWith(cameraId)) {
existingIdx = idx;
break;
}
}
}
if (existingIdx !== undefined) {
const existingRule = mapping.rules[existingIdx];
const existingOutputs = existingRule.outputs || [];
let nextId = Math.max(0, ...existingOutputs.map((o) => Number(o.id || 0))) + 1;
const mergedOutputs = [...existingOutputs];
for (const out of outputs) {
const key = outputKey(existingRule.input.name, out);
if (!existingKeys.has(key)) {
mergedOutputs.push({ ...out, id: String(nextId) });
nextId += 1;
existingKeys.add(key);
}
}
const existingFilters = isTelemetry
? (existingRule.filters || []).filter((f) => f.name !== '.Position' && f.name !== '.VideoOutput').map((f) => (f.name === '.VideoInput' ? { ...f, value: true } : f))
: (existingRule.filters || []).map((f) => (f.name === '.VideoInput' ? { ...f, value: true } : f));
if (isTelemetry && !existingFilters.some((f) => f.name === '.VideoInput')) {
existingFilters.push({ name: '.VideoInput', type: 'bool', value: true });
}
const existingFields = isTelemetry
? (existingRule.fields || []).filter((f) => f.name !== 'Position' && f.name !== 'VideoOutput')
: (existingRule.fields || []);
if (isFocusInputAction(actionName)) {
for (let i = existingFields.length - 1; i >= 0; i -= 1) {
if (existingFields[i]?.name === 'Temp') existingFields.splice(i, 1);
}
}
if (isTelemetry && !existingFields.some((f) => f.name === 'VideoInput')) {
existingFields.push({ name: 'VideoInput', type: 'int32', value: 0 });
}
mapping.rules[existingIdx] = {
...existingRule,
input: {
...existingRule.input,
videoInputId: existingRule.input.videoInputId ?? (cameraId ? Number(cameraId) : undefined),
ptz: typeof row.ptz === 'string' ? row.ptz.toUpperCase() === 'O' : existingRule.input.ptz,
temp: isFocusInputAction(actionName) ? undefined : existingRule.input.temp,
},
outputs: mergedOutputs,
filters: existingFilters,
fields: existingFields,
};
} else {
maxRuleId += 1;
const ruleId = String(maxRuleId);
mapping.rules.push({
id: ruleId,
input: {
name: inputName,
flags: chosenTemplate?.input?.flags || { primary: 0, secondary: 0 },
videoInputId: cameraId ? Number(cameraId) : undefined,
ptz: typeof row.ptz === 'string' ? row.ptz.toUpperCase() === 'O' : undefined,
temp: isFocusInputAction(actionName) ? undefined : chosenTemplate?.input?.temp,
},
outputs,
_order: chosenTemplate?._order,
filters: nextFilterSet,
fields: cleanedNextFields,
});
ruleIndexByName.set(normalizeInputName(inputName), mapping.rules.length - 1);
if (actionKey) ruleIndexByAction.set(actionKey, mapping.rules.length - 1);
}
}
fs.writeFileSync(process.argv[5], JSON.stringify(mapping));