/* Blackmagic Camera Control WebUI (c) Dylan Speiser 2024 — UI redesign 2025 */ var cameras = []; var ci = 0; var WBMode = 0; // 0: balance, 1: tint var unsavedChanges = []; function bodyOnLoad() { document.getElementById('hostnameInput').value = localStorage.getItem('camerahostname_' + ci) || ''; if (localStorage.getItem('camerasecurity_' + ci) === 'true') { document.getElementById('secureCheckbox').checked = true; } } // ===================================================================== // Tab switching // ===================================================================== function switchTab(name) { document.querySelectorAll('.tabPanel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.getElementById('tab-' + name).classList.add('active'); document.getElementById('tab-btn-' + name).classList.add('active'); } // ===================================================================== // Camera init / switch // ===================================================================== function initCamera() { const hostname = document.getElementById('hostnameInput').value; const security = document.getElementById('secureCheckbox').checked; const errorSpan = document.getElementById('connectionErrorSpan'); try { const response = sendRequest('GET', (security ? 'https://' : 'http://') + hostname + '/control/api/v1/system', ''); if (response.status < 300) { cameras[ci] = new BMCamera(hostname, security); localStorage.setItem('camerahostname_' + ci, hostname); localStorage.setItem('camerasecurity_' + ci, security); cameras[ci].updateUI = updateUIAll; cameras[ci].active = true; errorSpan.textContent = 'Connected.'; errorSpan.style.color = 'var(--accent)'; } else { errorSpan.textContent = response.statusText; errorSpan.style.color = 'var(--rec)'; } } catch (error) { errorSpan.title = error; errorSpan.textContent = 'Error ' + error.code + ': ' + error.name; errorSpan.style.color = 'var(--rec)'; } unsavedChanges = unsavedChanges.filter(e => e !== 'Hostname'); } function switchCamera(index) { if (cameras[ci]) cameras[ci].active = false; ci = index; document.querySelectorAll('.camBtn').forEach((btn, i) => { btn.classList.toggle('selected', i === ci); }); document.getElementById('cameraName').textContent = 'NOT CONNECTED'; document.getElementById('timecodeLabel').textContent = '--:--:--:--'; document.getElementById('hostnameInput').value = localStorage.getItem('camerahostname_' + ci) || ''; document.getElementById('secureCheckbox').checked = localStorage.getItem('camerasecurity_' + ci) === 'true'; if (cameras[ci]) cameras[ci].active = true; } // ===================================================================== // Main UI updater (called by WebSocket) // ===================================================================== function updateUIAll() { const cam = cameras[ci]; if (!cam) return; // Camera name document.getElementById('cameraName').textContent = cam.name; // Hostname if (!unsavedChanges.includes('Hostname')) { document.getElementById('hostnameInput').value = cam.hostname; } // Format display const fmt = cam.propertyData['/system/format']; document.getElementById('formatCodec').textContent = fmt?.codec?.toUpperCase().replace(':', ' ').replace('_', ':') || '—'; const res = fmt?.recordResolution; document.getElementById('formatResolution').textContent = res ? res.width + 'x' + res.height : '—'; document.getElementById('formatFPS').textContent = fmt?.frameRate ? fmt.frameRate + ' fps' : '—'; // Recording state const isRecording = cam.propertyData['/transports/0/record']?.recording; document.getElementById('topBar').classList.toggle('recording', !!isRecording); document.getElementById('recordButton').classList.toggle('recording', !!isRecording); document.getElementById('recLabel').textContent = isRecording ? 'RECORDING' : 'REC'; // Loop / single clip buttons const loopState = cam.propertyData['/transports/0/playback']?.loop; const singleClipState = cam.propertyData['/transports/0/playback']?.singleClip; document.getElementById('loopButton').classList.toggle('activated', !!loopState); document.getElementById('singleClipButton').classList.toggle('activated', !!singleClipState); // Timecode document.getElementById('timecodeLabel').textContent = parseTimecode(cam.propertyData['/transports/0/timecode']?.timecode); // Presets dropdown if (!unsavedChanges.includes('presets')) { const dd = document.getElementById('presetsDropDown'); dd.innerHTML = ''; cam.propertyData['/presets']?.presets?.forEach(item => { const name = item.split('.', 1)[0]; const opt = document.createElement('option'); opt.textContent = name; dd.appendChild(opt); }); dd.childNodes.forEach(child => { if (child.nodeName === 'OPTION') { child.selected = (child.value + '.cset') === cam.propertyData['/presets/active']?.preset; } }); } // Lens document.getElementById('irisRange').value = cam.propertyData['/lens/iris']?.normalised ?? 0; document.getElementById('apertureStopsLabel').textContent = cam.propertyData['/lens/iris']?.apertureStop != null ? 'f/' + cam.propertyData['/lens/iris'].apertureStop.toFixed(1) : '—'; document.getElementById('zoomRange').value = cam.propertyData['/lens/zoom']?.normalised ?? 0; document.getElementById('zoomMMLabel').textContent = cam.propertyData['/lens/zoom']?.focalLength != null ? cam.propertyData['/lens/zoom'].focalLength + 'mm' : '—mm'; document.getElementById('focusRange').value = cam.propertyData['/lens/focus']?.normalised ?? 0; // ISO if (!unsavedChanges.includes('ISO')) { const iso = cam.propertyData['/video/iso']?.iso; if (iso != null) { document.getElementById('ISODisplay').textContent = iso; document.getElementById('ISOInput').value = iso; } } // Gain if (!unsavedChanges.includes('Gain')) { const gain = cam.propertyData['/video/gain']?.gain; if (gain != null) { document.getElementById('gainDisplay').textContent = (gain >= 0 ? '+' : '') + gain + 'dB'; } } // White balance if (!unsavedChanges.includes('WB')) { const wb = cam.propertyData['/video/whiteBalance']?.whiteBalance; if (wb != null) document.getElementById('whiteBalanceDisplay').textContent = wb + 'K'; } if (!unsavedChanges.includes('WBT')) { const wbt = cam.propertyData['/video/whiteBalanceTint']?.whiteBalanceTint; if (wbt != null) document.getElementById('whiteBalanceTintDisplay').textContent = wbt; } // ND filter if (!unsavedChanges.includes('ND')) { const nd = cam.propertyData['/video/ndFilter']; document.getElementById('ndFilterDisplay').textContent = nd ? nd.stop : '0'; } // Shutter if (!unsavedChanges.includes('Shutter')) { const shutter = cam.propertyData['/video/shutter']; let shutterStr = '—'; if (shutter?.shutterSpeed) { shutterStr = '1/' + shutter.shutterSpeed; } else if (shutter?.shutterAngle) { const angle = (shutter.shutterAngle / 100).toFixed(1); shutterStr = (angle.endsWith('.0') ? parseFloat(angle).toFixed(0) : angle) + '°'; } document.getElementById('shutterDisplay').textContent = shutterStr; } // AE mode/type if (!unsavedChanges.includes('AutoExposure')) { document.getElementById('AEmodeDropDown').value = cam.propertyData['/video/autoExposure']?.mode || 'Off'; document.getElementById('AEtypeDropDown').value = cam.propertyData['/video/autoExposure']?.type || ''; } // Color correction — Lift if (!unsavedChanges.includes('CC0')) { const lift = cam.propertyData['/colorCorrection/lift']; if (lift) { document.getElementsByClassName('CClumaLabel')[0].textContent = lift.luma?.toFixed(2); document.getElementsByClassName('CCredLabel')[0].textContent = lift.red?.toFixed(2); document.getElementsByClassName('CCgreenLabel')[0].textContent = lift.green?.toFixed(2); document.getElementsByClassName('CCblueLabel')[0].textContent = lift.blue?.toFixed(2); } } // Gamma if (!unsavedChanges.includes('CC1')) { const gamma = cam.propertyData['/colorCorrection/gamma']; if (gamma) { document.getElementsByClassName('CClumaLabel')[1].textContent = gamma.luma?.toFixed(2); document.getElementsByClassName('CCredLabel')[1].textContent = gamma.red?.toFixed(2); document.getElementsByClassName('CCgreenLabel')[1].textContent = gamma.green?.toFixed(2); document.getElementsByClassName('CCblueLabel')[1].textContent = gamma.blue?.toFixed(2); } } // Gain CC if (!unsavedChanges.includes('CC2')) { const gainCC = cam.propertyData['/colorCorrection/gain']; if (gainCC) { document.getElementsByClassName('CClumaLabel')[2].textContent = gainCC.luma?.toFixed(2); document.getElementsByClassName('CCredLabel')[2].textContent = gainCC.red?.toFixed(2); document.getElementsByClassName('CCgreenLabel')[2].textContent = gainCC.green?.toFixed(2); document.getElementsByClassName('CCblueLabel')[2].textContent = gainCC.blue?.toFixed(2); } } // Offset if (!unsavedChanges.includes('CC3')) { const offset = cam.propertyData['/colorCorrection/offset']; if (offset) { document.getElementsByClassName('CClumaLabel')[3].textContent = offset.luma?.toFixed(2); document.getElementsByClassName('CCredLabel')[3].textContent = offset.red?.toFixed(2); document.getElementsByClassName('CCgreenLabel')[3].textContent = offset.green?.toFixed(2); document.getElementsByClassName('CCblueLabel')[3].textContent = offset.blue?.toFixed(2); } } // Contrast if (!unsavedChanges.includes('CC4')) { const contrast = cam.propertyData['/colorCorrection/contrast']; if (contrast) { document.getElementById('CCcontrastPivotRange').value = contrast.pivot; document.getElementById('CCcontrastPivotLabel').textContent = contrast.pivot?.toFixed(2); document.getElementById('CCcontrastAdjustRange').value = contrast.adjust; document.getElementById('CCcontrastAdjustLabel').textContent = parseInt(contrast.adjust * 50) + '%'; } } // Color / hue / sat if (!unsavedChanges.includes('CC5')) { const color = cam.propertyData['/colorCorrection/color']; if (color) { document.getElementById('CChueRange').value = color.hue; document.getElementById('CCcolorHueLabel').textContent = parseInt((color.hue + 1) * 180) + '°'; document.getElementById('CCsaturationRange').value = color.saturation; document.getElementById('CCcolorSatLabel').textContent = parseInt(color.saturation * 50) + '%'; } const lc = cam.propertyData['/colorCorrection/lumaContribution']; if (lc) { document.getElementById('CClumaContributionRange').value = lc.lumaContribution; document.getElementById('CCcolorLCLabel').textContent = parseInt(lc.lumaContribution * 100) + '%'; } } // Color science (new) updateColorScienceUI(); // Footer links document.getElementById('documentationLink').href = (cam.useHTTPS ? 'https://' : 'http://') + cam.hostname + '/control/documentation.html'; document.getElementById('mediaManagerLink').href = (cam.useHTTPS ? 'https://' : 'http://') + cam.hostname; } // ===================================================================== // Color Science (new) // ===================================================================== function updateColorScienceUI() { const cam = cameras[ci]; if (!cam) return; const cs = cam.propertyData['/video/colorScience']; if (!cs) return; const gamma = cs.gamma || ''; const gamut = cs.gamut || ''; document.getElementById('currentGamma').textContent = gamma || '—'; document.getElementById('currentGamut').textContent = gamut || '—'; // Highlight selected gamma button document.querySelectorAll('#gammaOptions .csOptionBtn').forEach(btn => { btn.classList.toggle('selected', btn.dataset.gamma === gamma); }); // Highlight selected gamut button document.querySelectorAll('#gamutOptions .csOptionBtn').forEach(btn => { btn.classList.toggle('selected', btn.dataset.gamut === gamut); }); } function setGamma(btn) { if (!cameras[ci]) return; const gamma = btn.dataset.gamma; cameras[ci].setColorScience(null, gamma); // Optimistic UI update if (!cameras[ci].propertyData['/video/colorScience']) { cameras[ci].propertyData['/video/colorScience'] = {}; } cameras[ci].propertyData['/video/colorScience'].gamma = gamma; updateColorScienceUI(); } function setGamut(btn) { if (!cameras[ci]) return; const gamut = btn.dataset.gamut; cameras[ci].setColorScience(gamut, null); if (!cameras[ci].propertyData['/video/colorScience']) { cameras[ci].propertyData['/video/colorScience'] = {}; } cameras[ci].propertyData['/video/colorScience'].gamut = gamut; updateColorScienceUI(); } const COLOR_SCIENCE_PRESETS = { 'braw-film': { gamut: 'Blackmagic Wide Gamut', gamma: 'Blackmagic Design Film' }, 'braw-video': { gamut: 'Blackmagic Design', gamma: 'Blackmagic Design Video' }, 'braw-ext': { gamut: 'Blackmagic Design', gamma: 'Blackmagic Design Extended Video' }, 'rec709': { gamut: 'Rec.709', gamma: 'Rec709' }, }; function applyColorSciencePreset(presetKey) { if (!cameras[ci]) return; const preset = COLOR_SCIENCE_PRESETS[presetKey]; if (!preset) return; cameras[ci].setColorScience(preset.gamut, preset.gamma); if (!cameras[ci].propertyData['/video/colorScience']) { cameras[ci].propertyData['/video/colorScience'] = {}; } Object.assign(cameras[ci].propertyData['/video/colorScience'], preset); updateColorScienceUI(); } // ===================================================================== // Video format (new) // ===================================================================== function applyVideoFormat() { if (!cameras[ci]) return; const codec = document.getElementById('codecSelect').value; const frameRate = document.getElementById('frameRateSelect').value; const statusEl = document.getElementById('formatSetStatus'); const data = {}; if (codec) data.codec = codec; if (frameRate) data.frameRate = frameRate; if (!Object.keys(data).length) { statusEl.textContent = 'Select a codec or frame rate first.'; return; } cameras[ci].PUTdata('/system/format', data); statusEl.textContent = 'Sent.'; setTimeout(() => { statusEl.textContent = ''; }, 2000); } // ===================================================================== // Exposure controls // ===================================================================== function adjustISO(delta) { if (!cameras[ci]) return; const current = cameras[ci].propertyData['/video/iso']?.iso ?? 800; cameras[ci].PUTdata('/video/iso', { iso: current + delta }); } function ISOKeyHandler(event) { if (event.key === 'Enter') { event.preventDefault(); const val = parseInt(document.getElementById('ISODisplay').textContent); if (!isNaN(val)) cameras[ci].PUTdata('/video/iso', { iso: val }); unsavedChanges = unsavedChanges.filter(e => e !== 'ISO'); } else { unsavedChanges.push('ISO'); } } function ISOBlurHandler() { unsavedChanges = unsavedChanges.filter(e => e !== 'ISO'); } function ISOInputHandler() { if (event.key === 'Enter') { event.preventDefault(); cameras[ci].PUTdata('/video/iso', { iso: parseInt(document.getElementById('ISOInput').value) }); unsavedChanges = unsavedChanges.filter(e => e !== 'ISO'); } else { unsavedChanges.push('ISO'); } } function decreaseND() { if (!cameras[ci]) return; cameras[ci].PUTdata('/video/ndFilter', { stop: (cameras[ci].propertyData['/video/ndFilter']?.stop ?? 0) - 2 }); } function increaseND() { if (!cameras[ci]) return; cameras[ci].PUTdata('/video/ndFilter', { stop: (cameras[ci].propertyData['/video/ndFilter']?.stop ?? 0) + 2 }); } function NDFilterInputHandler() { if (event.key === 'Enter') { event.preventDefault(); cameras[ci].PUTdata('/video/ndFilter', { stop: parseInt(document.getElementById('ndFilterDisplay').textContent) }); unsavedChanges = unsavedChanges.filter(e => e !== 'ND'); } else { unsavedChanges.push('ND'); } } function decreaseGain() { if (!cameras[ci]) return; cameras[ci].PUTdata('/video/gain', { gain: (cameras[ci].propertyData['/video/gain']?.gain ?? 0) - 2 }); } function increaseGain() { if (!cameras[ci]) return; cameras[ci].PUTdata('/video/gain', { gain: (cameras[ci].propertyData['/video/gain']?.gain ?? 0) + 2 }); } function GainInputHandler() { if (event.key === 'Enter') { event.preventDefault(); cameras[ci].PUTdata('/video/gain', { gain: parseInt(document.getElementById('gainDisplay').textContent) }); unsavedChanges = unsavedChanges.filter(e => e !== 'Gain'); } else { unsavedChanges.push('Gain'); } } function decreaseShutter() { if (!cameras[ci]) return; const cam = cameras[ci]; if ('shutterSpeed' in (cam.propertyData['/video/shutter'] ?? {})) { cam.PUTdata('/video/shutter', { shutterSpeed: cam.propertyData['/video/shutter'].shutterSpeed + 10 }); } else { cam.PUTdata('/video/shutter', { shutterAngle: cam.propertyData['/video/shutter'].shutterAngle - 1000 }); } } function increaseShutter() { if (!cameras[ci]) return; const cam = cameras[ci]; if ('shutterSpeed' in (cam.propertyData['/video/shutter'] ?? {})) { cam.PUTdata('/video/shutter', { shutterSpeed: cam.propertyData['/video/shutter'].shutterSpeed - 10 }); } else { cam.PUTdata('/video/shutter', { shutterAngle: cam.propertyData['/video/shutter'].shutterAngle + 1000 }); } } function handleShutterInput() { const input = document.getElementById('shutterDisplay').textContent; if (event.key === 'Enter') { event.preventDefault(); const cam = cameras[ci]; if ('shutterSpeed' in (cam.propertyData['/video/shutter'] ?? {})) { const val = input.includes('1/') ? parseInt(input.substring(2)) : parseInt(input); cam.PUTdata('/video/shutter', { shutterSpeed: val }); } else { cam.PUTdata('/video/shutter', { shutterAngle: parseInt(parseFloat(input) * 100) }); } unsavedChanges = unsavedChanges.filter(e => e !== 'Shutter'); } else { unsavedChanges.push('Shutter'); } } function swapWBMode() { if (WBMode === 0) { document.getElementById('WBLabel').textContent = 'TINT ↕'; document.getElementById('WBValueContainer').classList.add('hidden'); document.getElementById('WBTintValueContainer').classList.remove('hidden'); WBMode = 1; } else { document.getElementById('WBLabel').textContent = 'WB ↕'; document.getElementById('WBValueContainer').classList.remove('hidden'); document.getElementById('WBTintValueContainer').classList.add('hidden'); WBMode = 0; } } function decreaseWhiteBalance() { if (!cameras[ci]) return; cameras[ci].PUTdata('/video/whiteBalance', { whiteBalance: (cameras[ci].propertyData['/video/whiteBalance']?.whiteBalance ?? 5600) - 50 }); } function increaseWhiteBalance() { if (!cameras[ci]) return; cameras[ci].PUTdata('/video/whiteBalance', { whiteBalance: (cameras[ci].propertyData['/video/whiteBalance']?.whiteBalance ?? 5600) + 50 }); } function WBInputHandler() { if (event.key === 'Enter') { event.preventDefault(); cameras[ci].PUTdata('/video/whiteBalance', { whiteBalance: parseInt(document.getElementById('whiteBalanceDisplay').textContent) }); unsavedChanges = unsavedChanges.filter(e => e !== 'WB'); } else { unsavedChanges.push('WB'); } } function decreaseWhiteBalanceTint() { if (!cameras[ci]) return; cameras[ci].PUTdata('/video/whiteBalanceTint', { whiteBalanceTint: (cameras[ci].propertyData['/video/whiteBalanceTint']?.whiteBalanceTint ?? 0) - 1 }); } function increaseWhiteBalanceTint() { if (!cameras[ci]) return; cameras[ci].PUTdata('/video/whiteBalanceTint', { whiteBalanceTint: (cameras[ci].propertyData['/video/whiteBalanceTint']?.whiteBalanceTint ?? 0) + 1 }); } function WBTInputHandler() { if (event.key === 'Enter') { event.preventDefault(); cameras[ci].PUTdata('/video/whiteBalanceTint', { whiteBalanceTint: parseInt(document.getElementById('whiteBalanceTintDisplay').textContent) }); unsavedChanges = unsavedChanges.filter(e => e !== 'WBT'); } else { unsavedChanges.push('WBT'); } } // ===================================================================== // Color correction // ===================================================================== function CCInputHandler(which) { if (event.key === 'Enter') { event.preventDefault(); setCCFromUI(which); } else { unsavedChanges.push('CC' + which); } } function setCCFromUI(which) { if (which < 4) { const luma = parseFloat(document.getElementsByClassName('CClumaLabel')[which].textContent); const red = parseFloat(document.getElementsByClassName('CCredLabel')[which].textContent); const green = parseFloat(document.getElementsByClassName('CCgreenLabel')[which].textContent); const blue = parseFloat(document.getElementsByClassName('CCblueLabel')[which].textContent); const obj = { red, green, blue, luma }; const endpoints = ['/colorCorrection/lift', '/colorCorrection/gamma', '/colorCorrection/gain', '/colorCorrection/offset']; cameras[ci].PUTdata(endpoints[which], obj); } else if (which === 4) { const pivot = parseFloat(document.getElementById('CCcontrastPivotLabel').textContent); const adjust = parseInt(document.getElementById('CCcontrastAdjustLabel').textContent) / 50.0; cameras[ci].PUTdata('/colorCorrection/contrast', { pivot, adjust }); } else { const hue = (parseInt(document.getElementById('CCcolorHueLabel').textContent) / 180.0) - 1.0; const sat = parseInt(document.getElementById('CCcolorSatLabel').textContent) / 50.0; const lc = parseInt(document.getElementById('CCcolorLCLabel').textContent) / 100.0; cameras[ci].PUTdata('/colorCorrection/color', { hue, saturation: sat }); cameras[ci].PUTdata('/colorCorrection/lumaContribution', { lumaContribution: lc }); } unsavedChanges = unsavedChanges.filter(e => !e.includes('CC' + which)); } function resetCC(which) { const resets = [ ['/colorCorrection/lift', { red: 0.0, green: 0.0, blue: 0.0, luma: 0.0 }], ['/colorCorrection/gamma', { red: 0.0, green: 0.0, blue: 0.0, luma: 0.0 }], ['/colorCorrection/gain', { red: 1.0, green: 1.0, blue: 1.0, luma: 1.0 }], ['/colorCorrection/offset', { red: 0.0, green: 0.0, blue: 0.0, luma: 0.0 }], ['/colorCorrection/contrast', { pivot: 0.5, adjust: 1.0 }], ]; if (which < 5) { cameras[ci].PUTdata(resets[which][0], resets[which][1]); } else { cameras[ci].PUTdata('/colorCorrection/color', { hue: 0.0, saturation: 1.0 }); cameras[ci].PUTdata('/colorCorrection/lumaContribution', { lumaContribution: 1.0 }); } unsavedChanges = unsavedChanges.filter(e => !e.includes('CC' + which)); } // ===================================================================== // Presets / AE / Manual API // ===================================================================== function presetInputHandler() { cameras[ci].PUTdata('/presets/active', { preset: document.getElementById('presetsDropDown').value + '.cset' }); unsavedChanges = unsavedChanges.filter(e => e !== 'presets'); } function AEmodeInputHandler() { cameras[ci].PUTdata('/video/autoExposure', { mode: document.getElementById('AEmodeDropDown').value, type: document.getElementById('AEtypeDropDown').value, }); unsavedChanges = unsavedChanges.filter(e => e !== 'AutoExposure'); } function manualAPICall() { const isGET = document.getElementById('requestTypeGET').checked; const endpoint = document.getElementById('manualRequestEndpointLabel').value; let body = ''; try { body = JSON.parse(document.getElementById('manualRequestBodyLabel').value); } catch (_) {} const response = sendRequest(isGET ? 'GET' : 'PUT', cameras[ci].APIAddress + endpoint, body); document.getElementById('manualRequestResponseP').textContent = JSON.stringify(response, null, 2); } function hostnameInputHandler() { if (event.key === 'Enter') { event.preventDefault(); unsavedChanges = unsavedChanges.filter(e => e !== 'Hostname'); initCamera(); } else { unsavedChanges.push('Hostname'); } } function loopHandler(callerString) { const playbackState = cameras[ci].propertyData['/transports/0/playback']; if (callerString === 'Loop') { playbackState.loop = !playbackState.loop; } else if (callerString === 'Single Clip') { playbackState.singleClip = !playbackState.singleClip; } cameras[ci].PUTdata('/transports/0/playback', playbackState); } // ===================================================================== // Helpers // ===================================================================== function parseTimecode(timecodeBCD) { if (timecodeBCD == null) return '--:--:--:--'; const noDF = timecodeBCD & 0x7fffffff; const str = parseInt(noDF.toString(16), 10).toString().padStart(8, '0'); return str.match(/.{1,2}/g).join(':'); }