diff --git a/BMDevice.js b/BMDevice.js index b20e9d8..128d9f6 100644 --- a/BMDevice.js +++ b/BMDevice.js @@ -245,6 +245,18 @@ class BMCamera extends BMDevice { doAutoWhitebalance() { this.PUTdata("/video/whiteBalance/doAuto"); } + + // gamut and gamma can each be null to leave the other unchanged + setColorScience(gamut, gamma) { + const data = {}; + if (gamut !== null && gamut !== undefined) data.gamut = gamut; + if (gamma !== null && gamma !== undefined) data.gamma = gamma; + if (Object.keys(data).length) this.PUTdata("/video/colorScience", data); + } + + setVideoFormat(data) { + this.PUTdata("/system/format", data); + } } /* Helper Functions */ diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..232a763 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,22 @@ +# BM Camera Control WebUI + +## Product Purpose +A browser-based remote control panel for Blackmagic Design cameras via the official REST API over a local network. Used on set to adjust camera parameters without touching the camera body. + +## Users +- Camera operators: need one-tap access to recording state and exposure controls +- DITs (Digital Imaging Technicians): need color science selection, color correction, and format settings +- Directors / ACs: need timecode and recording status visible at a glance +- All users may be working in dim environments (video village, studio) and on tablets + +## Register +product + +## Tone +Clinical and fast. Every control earns its place. No decorative chrome. Quiet when nothing is happening; clear when something requires attention (especially recording state). + +## Anti-references +- Neon SaaS dashboards +- Consumer camera apps (bright white, rounded, playful) +- Generic Bootstrap-dark or shadcn defaults +- Glassmorphism decoration diff --git a/index.html b/index.html index 92b0fac..3098310 100644 --- a/index.html +++ b/index.html @@ -1,349 +1,558 @@ - - - - - - Camera Control WebUI for Blackmagic Cameras - - - + + + + + BM Camera Control + + + + + - - - - - - - + +
+ - - - -
-

Camera Control WebUI for Blackmagic Cameras

+
+ NOT CONNECTED + --:--:--:--
- -
- CAM1 - | - CAM2 - | - CAM3 - | - CAM4 - | - CAM5 - | - CAM6 - | - CAM7 - | - CAM8 +
+ + +
- -
-
-
-

CAM1

-
+
+ + + + + + +
-
- - Lift -
- -
- 0.00 - 0.00 - 0.00 - 0.00 -
- -
+ +
- Gamma -
- -
- 0.00 - 0.00 - 0.00 - 0.00 -
- -
+ + - Gain -
- -
- 0.00 - 0.00 - 0.00 - 0.00 -
- -
- + +
- Offset -
- -
- 0.00 - 0.00 - 0.00 - 0.00 -
- + +
+
+ +
+ ISO +
+ + +
-
-
- FILTER -
- - 0 - -
-
-
- GAIN -
- - +0db - -
-
-
- SHUTTER -
- - 1/50 - -
-
-
- BALANCE -
- - 5600K - -
-
- - 0 - -
-
-
- +
+ SHUTTER +
+ + +
-
-
- FOCUS - - -
-
- IRIS - - X.X -
-
- ZOOM - - XXmm +
+ ND FILTER +
+ + +
- + +
+ GAIN +
+ + + +
+
+ +
+ WB ↕ +
+ + + +
+ + +
+
-
-
-

CAMERA NAME

-
- CODEC - RESOLUTION - FPS -
-
- - - - - - - - -
-

TIMECODE

+ +
+
+ + +
+
+ + +
+ + + +
+ +
+ + + + +
+
+
-
-
-

Connection

- - - - - - - - - - - - -
Hostname - - - - Use HTTPS - -
Send API Call - - - - + +
+
+ + +
+
+ - - - - -
-

Send manual API requests using the above controls. See documentation for details.

-
+ +
+ +
+
+

Gamma Curve

+
+ + + + + + + +
+
+ Current: + +
+
+ +
+

Color Gamut

+
+ + + + + + + +
+
+ Current: + +
+
+ +
+

Quick Presets

+
+ + + + +
+
+
+ + +
+ Video Format +
+ +
+
+ + +
+
+ + +
+ + +
+ +
+ + +
+ +
+ +
+ Lift +
+
+ L + 0.00
- -
-

Presets

- - - - - - -
Preset Select - - - -
+
+ R + 0.00
- -
-

Exposure

- - - - - - - - - - - - - -
ISO
AE Mode - -
AE Type - -
- +
+ G + 0.00
- -
-

Contrast

- - - - - - - - - - - - -
Pivot - 0 - - -
Adjust - 0 -
-
- -
-

Color

- - - - - - - - - - - - - - - - - -
Hue - 0 - - -
Saturation - 0 -
Luma Contribution - 0 -
+
+ B + 0.00
-
- -
+ + +
- -
-
- (v 1.4.2) - + +
+ Gamma +
+
+ L + 0.00 +
+
+ R + 0.00 +
+
+ G + 0.00 +
+
+ B + 0.00 +
+
+ + +
+ + +
+ Gain +
+
+ L + 1.00 +
+
+ R + 1.00 +
+
+ G + 1.00 +
+
+ B + 1.00 +
+
+ + +
+ + +
+ Offset +
+
+ L + 0.00 +
+
+ R + 0.00 +
+
+ G + 0.00 +
+
+ B + 0.00 +
+
+ + +
- - - \ No newline at end of file + + +
Color
+
+
+ + + 180° +
+
+ + + 50% +
+
+ + + 100% +
+ +
+ +
+ + +
+
+ +
+ FOCUS + + +
+ +
+ IRIS + + f/— +
+ +
+ ZOOM + + —mm +
+ +
+
+ + +
+ +
+
+

Connection

+
+ + +
+
+ + + + +
+
+ +
+

Presets

+
+ + +
+
+ +
+

Manual API

+
+ + + + + +
+

+ See BM REST API docs for available endpoints. +

+
+ +
+

Links

+ +
+
+ +
+ +
+ + + + + + diff --git a/style.css b/style.css index e71a457..123b75d 100644 --- a/style.css +++ b/style.css @@ -1,470 +1,770 @@ -/* Sorry the CSS is such a mess. -DS */ +/* Design tokens */ +:root { + --bg: oklch(0.11 0.008 250); + --surface: oklch(0.17 0.008 250); + --elevated: oklch(0.22 0.009 250); + --border: oklch(0.28 0.010 250); + --border-subtle: oklch(0.22 0.008 250); -/* ============= WHOLE PAGE STYLES ================== */ + --text: oklch(0.92 0.005 250); + --text-2: oklch(0.62 0.008 250); + --text-3: oklch(0.42 0.008 250); -/* Handle vertical screens */ -@media screen and (max-width: 1400px) and (orientation: portrait) { - #cameraControlsContainer { - width: 100vw!important; - } + --accent: oklch(0.78 0.14 76); + --accent-dim: oklch(0.78 0.14 76 / 0.14); + --accent-border: oklch(0.78 0.14 76 / 0.4); - #cameraControlsContainerExpanded { - display: none!important; - } + --rec: oklch(0.58 0.22 22); + --rec-dim: oklch(0.58 0.22 22 / 0.18); + --rec-border: oklch(0.58 0.22 22 / 0.5); - body { - font-size: 125%; - } + --ch-luma: oklch(0.74 0.006 250); + --ch-red: oklch(0.66 0.18 22); + --ch-green: oklch(0.70 0.16 145); + --ch-blue: oklch(0.65 0.15 265); + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + + --top-bar-h: 52px; + --tab-bar-h: 40px; + --status-h: 28px; } -@media screen and (max-width: 1400px) and (orientation: landscape) { - #cameraControlsContainer { - width: 30vw!important; - } +/* Reset */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - #headerContainer { - height: 5vh!important; - } - - body { - font-size: 70%; - } -} - -/* Load NotoSansDisplay Font from resources */ -@font-face { - font-family: 'NotoSansDisplay'; - src: url('resources/NotoSansDisplay-VariableFont_wdth\,wght.woff') format('woff'), /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ - url('resources/NotoSansDisplay-VariableFont_wdth\,wght.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5, Opera 10+, Safari 3—5 */ -} - -.dNone { - display: none; -} - -body { - font-family: 'NotoSansDisplay', sans-serif; - font-weight: 100; - margin: 0px; - overflow: hidden; - background: #181818; - color: white; -} - -/* Page Body Flexboxes */ -.flexContainerH { - display: flex; -} - -.flexContainerV { - display: inline-flex; -} - -/* Inputs */ - -input[type=text] { - border-radius: 0.5vh; - background: rgb(30, 30, 30); - color: white; - height: 2em; - width: 10vw; - border: 1px solid rgb(20, 20, 20); - text-align: center; - font-family: 'NotoSansDisplay', sans-serif; - outline: none; - margin: 0px 0.5vw; -} - -input[type=text]:focus { - border: 1px solid rgb(150, 58, 0); -} - -input[type=number] { - border-radius: 0.5vh; - background: rgb(30, 30, 30); - color: white; - height: 2em; - width: 4.666vw; - border: 1px solid rgb(20, 20, 20); - text-align: center; - font-family: 'NotoSansDisplay', sans-serif; - outline: none; -} - -input[type=number]:focus { - border: 1px solid rgb(150, 58, 0); -} - -input[type=range][orient=vertical] { - writing-mode: vertical-lr; direction: rtl; - width: 2vw; - height: 80%; -} - -select { - border-radius: 0.5vh; - background: rgb(30, 30, 30); - color: white; - height: 2em; - width: 5vw; - border: 1px solid rgb(20, 20, 20); - text-align: center; - font-family: 'NotoSansDisplay', sans-serif; - outline: none; -} - -select:focus { - border: 1px solid rgb(150, 58, 0); -} - -button { - font-family: 'NotoSansDisplay', sans-serif; - background: #181818; - background: linear-gradient(0deg, #232323 0%, #404040 100%); - border: 0.3vh solid black; - outline: 0.3vh solid #404040; - color: white; - height: fit-content; - width: fit-content; - border-radius: 0.4em; - padding: 0.5vh 1vw; - margin: 1vh; -} - -button:hover { - background: #313131; - background: linear-gradient(0deg, #313131 0%, #4d4d4d 100%); -} - -button:active { - background: #181818; - box-shadow: inset 0 0 1em #090909; -} - -input[type=file]:focus { - border: 1px solid rgb(150, 58, 0); -} - -#secureCheckboxLabel { - margin-right: 1vw; -} - -/* Horizontal Container Styles */ -#headerContainer { - background: #181818; - background: linear-gradient(0deg, #181818 0%, #303030 100%); - border-bottom: 1px solid black; - width: 100%; - height: 7.77vh; - flex-wrap: wrap; - align-content: center; -} - -#headerContainer h1 { - color: white; - margin-left: 1.3vw; - font-weight: 100; -} - -#cameraSelectContainer { - background: #222222; - border: 1px solid black; - width: 100%; - height: 3.53vh; - color: #474747; - font-size: 1em; - align-items: center; - justify-content: center; -} - -#cameraSelectContainer span { - height: fit-content; -} - -#cameraSelectContainer .camSelectSeparator { - margin: 0px 0.5em; -} - -.cameraSwitchLabel a { - text-decoration: none; - color: #474747; -} - -.cameraSwitchLabel.selectedCam a { - color: #e66c01; -} - -#allCamerasContainer { - width: 100%; - height: 83.1vh; - - overflow-x: scroll; - overflow-y: hidden; - - scrollbar-color: #202020 #151515; -} - -#formatDisplay span { - margin: 0px 1em; -} - -#footerContainer { - background: #181818; - background: linear-gradient(0deg, #181818 0%, #303030 100%); - border: 1px solid black; - width: 100%; - height: 5vh; - position: fixed; - bottom: 0; - align-items: center; - justify-content: space-between; -} - -#footerContainer button { - padding: 0.4em 0.8em; - font-size: x-small; -} - -#refreshingText.refreshing { - color: #606060ff; -} - -#refreshingText { - color: #60606000; -} - -#footerLinks span { - margin-right: 1.25vw; -} - -#footerLeft span { - margin-left: 1.25vw; -} - -#footerLinks a { - text-decoration: none; - color: #e66c01; -} - -/* Camera Controls Container */ -#cameraControlsContainer { - width: 15vw; +html, body { height: 100%; - background: #282828; - flex-direction: column; - border: 1px solid black; + background: var(--bg); + color: var(--text); + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; + font-size: 13px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ============================ + TOP BAR + ============================ */ + +#topBar { + position: fixed; + top: 0; left: 0; right: 0; + height: var(--top-bar-h); + background: var(--surface); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px; + z-index: 100; +} + +#camSwitcher { + display: flex; + gap: 2px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 3px; flex-shrink: 0; } -.liveCam { - background: rgb(184,3,16); - background: linear-gradient(90deg, rgba(184,3,16,1) 0%, rgba(255,0,19,1) 15%, rgba(255,0,19,1) 85%, rgba(184,3,16,1) 100%); +.camBtn { + background: none; + border: none; + color: var(--text-2); + font: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + padding: 4px 8px; + border-radius: 5px; + cursor: pointer; + transition: color 0.12s, background 0.12s; } +.camBtn:hover { color: var(--text); background: var(--elevated); } +.camBtn.selected { color: var(--accent); background: var(--accent-dim); } -#cameraControlHeadContainer { - width: 100%; - height: 5vh; - border-bottom: 2px solid black; - align-items: center; - color: white; -} +#topBar.recording { border-bottom-color: var(--rec-border); } -h2 { - margin-inline-start: 0.6em; - margin-inline-end: 0.6em; - font-weight: 100; +#camInfo { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 140px; } #cameraName { - -} - -/* Color Correction Section */ -#cameraControlColorCorrectionContainer { - width: 100%; - height: 33vh; - flex-direction: column; - align-items: center; - justify-content: space-between; -} - -#cameraControlLGGTabs { - margin-top: 2vh; - border: 1px solid #101010; - border-radius: 2.5vh; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--text); + white-space: nowrap; overflow: hidden; + text-overflow: ellipsis; } - -#cameraControlLGGTabs a { - background: #181818; - background: linear-gradient(0deg, #181818 0%, #303030 100%); - - padding: 0.1vh 1.25vw 0.25vh 1.25vw; - - border-left: 1px solid #101010; - - text-decoration: none; - color: #474747; +.timecode { + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--text-2); + letter-spacing: 0.08em; } -#cameraControlLGGTabs a.selectedTab { - color: #e66c01; -} - -.ccExposureSettingValueContainer .expAdjArr { - text-decoration: none; - color: #60606000; -} - -.ccExposureSettingValueContainer:hover .expAdjArr { - color: #606060ff; -} - -.ccExposureSettingValueContainer:hover .expAdjArr:hover { - color: #474747; -} - -.ccExposureSettingValueContainer:hover .expAdjArr:active { - color: #e66c01; -} - -#cameraControlColorCorrectionBottomContainer { - margin-bottom: 1em; - display: inline-flex; - align-items: center; -} - -#cameraControlColorCorrectionBottomContainer button { - margin: 0px 0.5em; -} - -#cameraControlColorCorrectionNumbersContainer span { - margin: 0px 0.5em; - text-decoration: underline 3px; -} - -/* Exposure Section */ -#cameraControlExposureContainer { - width: 100%; - height: 4.7vh; - background-color: #171717; - border-top: 1px solid #2d2d2d; - border-bottom: 1px solid #2d2d2d; - display: inline-flex; - justify-content: space-evenly; - overflow: hidden; -} - -.ccExposureSettingContainer { +#formatDisplay { display: flex; - color: white; - font-size: 0.86em; - flex-direction: column; - align-content: center; - justify-content: center; - align-items: center; - padding-bottom: 0.5vh; - padding-top: 0.25vh; + gap: 8px; + font-size: 11px; + color: var(--text-2); + letter-spacing: 0.04em; } +#formatCodec { color: var(--text); font-weight: 600; } -.exposureControlLabel { - color: #6e6e6e; - font-size: 0.666em; - display: block; -} - -/* Lens Stuff */ -#cameraControlLensContainer { - width: 100%; - height: 41.9vh; - border-bottom: 1px solid black; - justify-content: space-evenly; -} - -.lensSliderContainer { +#transportControls { + display: flex; + gap: 4px; + margin-left: auto; +} + +.iconBtn { + width: 32px; height: 32px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-2); + font-size: 13px; + cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: color 0.1s, border-color 0.1s, background 0.1s; +} +.iconBtn:hover { color: var(--text); border-color: var(--text-3); background: var(--elevated); } +.iconBtn.activated { color: var(--accent); border-color: var(--accent-border); background: var(--accent-dim); } + +#recordButton { display: flex; - flex-direction: column; align-items: center; + gap: 6px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-2); + font: inherit; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + padding: 6px 12px; + cursor: pointer; + transition: all 0.1s; + flex-shrink: 0; } +#recordButton:hover { border-color: var(--rec-border); color: var(--rec); } +#recordButton.recording { border-color: var(--rec-border); color: var(--rec); background: var(--rec-dim); } -.lensSliderContainer span { - margin-top: 1em; - margin-bottom: 1em; -} - -.circleButton { - width: 2em; - height: 2em; - border-radius: 1em; - padding: 0; - margin: 1em 0; -} - -.activated { - background: #181818; - box-shadow: inset 0 0 1em #090909; -} - -#transportControls .circleButton { - margin: 0 0.5em; -} - -/* Right side (expanded) */ -#cameraControlsContainerExpanded { - width: 84.75vw; - height: 100%; - background: #282828; - flex-direction: column; - border: 1px solid black; +#recDot { + width: 8px; height: 8px; + border-radius: 50%; + background: var(--rec); flex-shrink: 0; } -#cameraControlExpandedHeadContainer { - width: 100%; - height: 5vh; - border-bottom: 2px solid black; +#recordButton.recording #recDot { + animation: recPulse 1s ease-in-out infinite; +} + +@keyframes recPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* ============================ + TAB BAR + ============================ */ + +#tabBar { + position: fixed; + top: var(--top-bar-h); + left: 0; right: 0; + height: var(--tab-bar-h); + background: var(--bg); + border-bottom: 1px solid var(--border); + display: flex; + align-items: stretch; + padding: 0 12px; + z-index: 90; +} + +.tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-2); + font: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + padding: 0 16px; + cursor: pointer; + transition: color 0.12s, border-color 0.12s; + white-space: nowrap; +} +.tab:hover { color: var(--text); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } + +/* ============================ + MAIN CONTENT + ============================ */ + +#mainContent { + position: fixed; + top: calc(var(--top-bar-h) + var(--tab-bar-h)); + bottom: var(--status-h); + left: 0; right: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.tabPanel { display: none; padding: 20px; } +.tabPanel.active { display: block; } + +/* ============================ + EXPOSURE TAB + ============================ */ + +.exposureRow { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.expPill { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 140px; + flex: 1; +} + +.expPillLabel { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--text-3); + text-transform: uppercase; +} + +.expPillValue { + display: flex; + align-items: center; + gap: 8px; +} + +.expValue { + font-size: 20px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text); + flex: 1; + text-align: center; + outline: none; + min-width: 60px; +} +.expValue:focus { color: var(--accent); } + +.adjBtn { + width: 28px; height: 28px; + background: none; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-3); + font-size: 10px; + cursor: pointer; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + transition: color 0.1s, border-color 0.1s; +} +.adjBtn:hover { color: var(--text); border-color: var(--border); } +.adjBtn:active { background: var(--elevated); } + +.awbBtn { + background: none; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-3); + font: inherit; + font-size: 10px; + font-weight: 700; + padding: 4px 8px; + cursor: pointer; + transition: color 0.1s, border-color 0.1s; +} +.awbBtn:hover { color: var(--accent); border-color: var(--accent-border); } + +.hidden { display: none !important; } + +.settingsRow { + display: flex; + align-items: flex-end; + gap: 16px; + flex-wrap: wrap; + padding: 12px 0; + border-top: 1px solid var(--border-subtle); +} + +.settingGroup { + display: flex; + flex-direction: column; + gap: 4px; +} + +.settingLabel { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--text-3); + text-transform: uppercase; +} + +.settingSelect { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font: inherit; + font-size: 12px; + padding: 6px 10px; + cursor: pointer; + outline: none; +} +.settingSelect:focus { border-color: var(--accent); } + +.settingInput { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font: inherit; + font-size: 12px; + padding: 6px 10px; + outline: none; +} +.settingInput:focus { border-color: var(--accent); } + +.actionBtn { + background: var(--elevated); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font: inherit; + font-size: 11px; + font-weight: 600; + padding: 7px 14px; + cursor: pointer; + transition: background 0.1s, border-color 0.1s; + white-space: nowrap; + align-self: flex-end; +} +.actionBtn:hover { background: var(--border); border-color: var(--text-3); } + +.presetBtnRow { display: flex; gap: 4px; } + +.presetBtn { + background: none; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-2); + font: inherit; + font-size: 11px; + padding: 5px 10px; + cursor: pointer; + transition: all 0.1s; +} +.presetBtn:hover { color: var(--accent); border-color: var(--accent-border); background: var(--accent-dim); } + +/* ============================ + COLOR SCIENCE TAB + ============================ */ + +.csGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 24px; +} + +.csCard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} +.csCard--full { grid-column: 1 / -1; } + +.csCardTitle { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--text-3); + text-transform: uppercase; +} + +.csOptions { display: flex; flex-wrap: wrap; gap: 6px; } + +.csOptionBtn { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text-2); + font: inherit; + font-size: 12px; + font-weight: 600; + padding: 8px 14px; + cursor: pointer; + transition: all 0.12s; +} +.csOptionBtn:hover { color: var(--text); border-color: var(--text-3); background: var(--elevated); } +.csOptionBtn.selected { color: var(--accent); border-color: var(--accent-border); background: var(--accent-dim); } + +.csCurrentRow { + display: flex; + align-items: baseline; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-subtle); +} +.csCurrentLabel { font-size: 10px; color: var(--text-3); letter-spacing: 0.06em; } +.csCurrentValue { font-size: 13px; font-weight: 600; color: var(--accent); } + +.csQuickPresets { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.csQuickBtn { + background: var(--elevated); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text); + font: inherit; + padding: 12px 14px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 4px; + text-align: left; + transition: all 0.12s; +} +.csQuickBtn:hover { border-color: var(--accent-border); background: var(--accent-dim); } + +.csQuickBtnName { font-size: 13px; font-weight: 700; } +.csQuickBtnDetail { font-size: 10px; color: var(--text-2); letter-spacing: 0.04em; } + +.formatGrid { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.statusNote { font-size: 11px; color: var(--text-2); align-self: center; } + +.sectionDivider { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 0 12px; + color: var(--text-3); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.sectionDivider::before, .sectionDivider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-subtle); +} + +/* ============================ + COLOR CORRECTION TAB + ============================ */ + +.ccGrid { + display: flex; + flex-direction: column; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 16px; +} + +.ccRow { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s; +} +.ccRow:last-child { border-bottom: none; } +.ccRow:hover { background: var(--elevated); } + +.ccRowLabel { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--text-2); + width: 52px; + flex-shrink: 0; +} + +.ccChannels { + display: flex; + gap: 24px; + flex: 1; +} + +.ccChannel { display: flex; align-items: center; gap: 6px; } + +.ccChLabel { + font-size: 11px; + font-weight: 700; + width: 12px; + flex-shrink: 0; +} + +.ccVal { + font-size: 14px; + font-variant-numeric: tabular-nums; + color: var(--text); + min-width: 44px; + outline: none; + text-align: right; +} +.ccVal:focus { color: var(--accent); } + +.ccApplyBtn, .ccResetBtn { + width: 30px; height: 30px; + background: none; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-3); + font-size: 14px; + cursor: pointer; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + transition: all 0.1s; +} +.ccApplyBtn:hover { color: var(--accent); border-color: var(--accent-border); } +.ccResetBtn:hover { color: var(--text); border-color: var(--border); } + +.sliderGroup { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} + +.sliderRow { display: flex; align-items: center; gap: 12px; } + +.sliderLabel { + font-size: 11px; + color: var(--text-2); + width: 110px; + flex-shrink: 0; +} + +.slider { + flex: 1; + height: 4px; + appearance: none; + background: var(--elevated); + border-radius: 2px; + outline: none; + cursor: pointer; +} +.slider::-webkit-slider-thumb { + appearance: none; + width: 14px; height: 14px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; +} + +.sliderVal { + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--text-2); + width: 44px; + text-align: right; + outline: none; +} +.sliderVal:focus { color: var(--accent); } + +/* ============================ + LENS TAB + ============================ */ + +.lensGrid { + display: flex; + gap: 24px; + justify-content: center; + padding: 20px; + height: calc(100vh - var(--top-bar-h) - var(--tab-bar-h) - var(--status-h) - 40px); +} + +.lensCard { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px 40px; + min-width: 120px; +} + +.lensCardLabel { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--text-3); + text-transform: uppercase; +} + +.vertSlider { + writing-mode: vertical-lr; + direction: rtl; + flex: 1; + width: 4px; + appearance: none; + background: var(--elevated); + border-radius: 2px; + outline: none; + cursor: pointer; +} +.vertSlider::-webkit-slider-thumb { + appearance: none; + width: 22px; height: 22px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + box-shadow: 0 0 0 4px var(--accent-dim); +} + +.lensActionBtn { + background: var(--elevated); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font: inherit; + font-size: 12px; + font-weight: 700; + padding: 8px 20px; + cursor: pointer; + transition: all 0.1s; +} +.lensActionBtn:hover { border-color: var(--accent-border); color: var(--accent); background: var(--accent-dim); } + +.lensValueLabel { + font-size: 13px; + font-variant-numeric: tabular-nums; + color: var(--text-2); + font-weight: 600; +} + +/* ============================ + SYSTEM TAB + ============================ */ + +.sysGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.sysCard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} +.sysCard--full { grid-column: 1 / -1; } + +.sysCardTitle { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--text-3); + text-transform: uppercase; +} + +.sysRow { display: flex; flex-direction: column; gap: 4px; } +.sysRow--inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; } + +.hostnameInput { min-width: 220px; } + +.apiResponse { + font-size: 11px; + color: var(--text-2); + background: var(--bg); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 8px 10px; + word-break: break-all; + min-height: 36px; +} + +.linkRow { display: flex; gap: 16px; flex-wrap: wrap; } +.sysLink { font-size: 12px; font-weight: 600; color: var(--accent); letter-spacing: 0.04em; } + +/* ============================ + STATUS BAR + ============================ */ + +#statusBar { + position: fixed; + bottom: 0; left: 0; right: 0; + height: var(--status-h); + background: var(--bg); + border-top: 1px solid var(--border-subtle); + display: flex; align-items: center; justify-content: space-between; + padding: 0 12px; + font-size: 10px; + color: var(--text-3); + letter-spacing: 0.04em; + z-index: 100; } -#cameraControlExpandedBodyContainer { - flex-direction: column; - display: inline-flex; - height: inherit; - justify-content: space-around; -} +.version { opacity: 0.5; } -#timecodeLabel { - -} - -#connectionErrorSpan { - color: #e64b3d; -} - -#manualRequestResponseP { - color: #6e6e6e; -} - -/* Table Controls */ - -.tableControl { - margin-left: 1.5vw; -} - -.tableControl h3 { - font-weight: 100; - margin: 0.5vh 0vw; -} - -table, td { - margin-left: 0.5vw; - /* border: 1px solid white; */ - border-collapse: collapse; - padding: 0.1vw 1vw; -} - -td, tr { - align-items: center; -} \ No newline at end of file +#mainContent::-webkit-scrollbar { width: 6px; } +#mainContent::-webkit-scrollbar-track { background: transparent; } +#mainContent::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } diff --git a/web-ui.js b/web-ui.js index 83b1df5..995b021 100644 --- a/web-ui.js +++ b/web-ui.js @@ -1,655 +1,648 @@ -/* Blackmagic Camera Control WebUI - WebUI Script functions - (c) Dylan Speiser 2024 - github.com/DylanSpeiser +/* Blackmagic Camera Control WebUI + (c) Dylan Speiser 2024 — UI redesign 2025 */ - -/* Global variables */ -var cameras = []; // Array to store all of the camera objects -var ci = 0; // Index into this array for the currently selected camera. -// cameras[ci] is used to reference the currently selected camera object - -var WBMode = 0; // 0: balance, 1: tint - -var defaultControlsHTML; - +var cameras = []; +var ci = 0; +var WBMode = 0; // 0: balance, 1: tint var unsavedChanges = []; -// Set everything up function bodyOnLoad() { - defaultControlsHTML = document.getElementById("allCamerasContainer").innerHTML; - // prefill camera hostname (or IP address) - document.getElementById("hostnameInput").value = localStorage.getItem("camerahostname_"+ci.toString()); - if ( localStorage.getItem("camerasecurity_"+ci.toString()) === 'true' ) { - document.getElementById("secureCheckbox").checked = true + 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 +// ===================================================================== -// Checks the hostname, if it replies successfully then a new BMCamera object -// is made and gets put in the array at ind function initCamera() { - // Get hostname from Hostname text field - let hostname = document.getElementById("hostnameInput").value; - let security = document.getElementById("secureCheckbox").checked; + const hostname = document.getElementById('hostnameInput').value; + const security = document.getElementById('secureCheckbox').checked; + const errorSpan = document.getElementById('connectionErrorSpan'); try { - // Check if the hostname is valid - let response = sendRequest("GET", (security ? "https://" : "http://")+hostname+"/control/api/v1/system",""); - + const response = sendRequest('GET', (security ? 'https://' : 'http://') + hostname + '/control/api/v1/system', ''); if (response.status < 300) { - // Success, make a new camera, get all relevant info, and populate the UI cameras[ci] = new BMCamera(hostname, security); - // Save camera hostname and security status in local storage - localStorage.setItem("camerahostname_"+ci, hostname) - localStorage.setItem("camerasecurity_"+ci, security) + localStorage.setItem('camerahostname_' + ci, hostname); + localStorage.setItem('camerasecurity_' + ci, security); cameras[ci].updateUI = updateUIAll; - cameras[ci].active = true; - - document.getElementById("connectionErrorSpan").innerHTML = "Connected."; - document.getElementById("connectionErrorSpan").setAttribute("style","color: #6e6e6e;"); - + errorSpan.textContent = 'Connected.'; + errorSpan.style.color = 'var(--accent)'; } else { - // Something has gone wrong, tell the user - document.getElementById("connectionErrorSpan").innerHTML = response.statusText; + errorSpan.textContent = response.statusText; + errorSpan.style.color = 'var(--rec)'; } } catch (error) { - // Something has gone wrong, tell the user - document.getElementById("connectionErrorSpan").title = error; - document.getElementById("connectionErrorSpan").innerHTML = "Error "+error.code+": "+error.name+" (Your hostname is probably incorrect, hover for more details)"; + errorSpan.title = error; + errorSpan.textContent = 'Error ' + error.code + ': ' + error.name; + errorSpan.style.color = 'var(--rec)'; } - - unsavedChanges = unsavedChanges.filter((e) => {return e !== "Hostname"}); + unsavedChanges = unsavedChanges.filter(e => e !== 'Hostname'); } -// =============================== UI Updater ================================== -// ============================================================================= +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() { - // ========== Camera Name ========== + const cam = cameras[ci]; + if (!cam) return; - document.getElementById("cameraName").innerHTML = cameras[ci].name; + // Camera name + document.getElementById('cameraName').textContent = cam.name; - // ========== Hostname ========== - - if (!unsavedChanges.includes("Hostname")) { - document.getElementById("hostnameInput").value = cameras[ci].hostname; + // Hostname + if (!unsavedChanges.includes('Hostname')) { + document.getElementById('hostnameInput').value = cam.hostname; } - // ========== Format ========== + // 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' : '—'; - document.getElementById("formatCodec").innerHTML = cameras[ci].propertyData['/system/format']?.codec.toUpperCase().replace(":"," ").replace("_",":"); - - let resObj = cameras[ci].propertyData['/system/format']?.recordResolution; - document.getElementById("formatResolution").innerHTML = resObj?.width + "x" + resObj?.height; - document.getElementById("formatFPS").innerHTML = cameras[ci].propertyData['/system/format']?.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'; - // ========== Recording State ========== + // 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); - if (cameras[ci].propertyData['/transports/0/record']?.recording) { - document.getElementById("cameraControlHeadContainer").classList.add("liveCam"); - document.getElementById("cameraControlExpandedHeadContainer").classList.add("liveCam"); - } else { - document.getElementById("cameraControlHeadContainer").classList.remove("liveCam"); - document.getElementById("cameraControlExpandedHeadContainer").classList.remove("liveCam"); - } + // Timecode + document.getElementById('timecodeLabel').textContent = + parseTimecode(cam.propertyData['/transports/0/timecode']?.timecode); - // ========== Playback Loop State ========== - let loopState = cameras[ci].propertyData['/transports/0/playback']?.loop; - let singleClipState = cameras[ci].propertyData['/transports/0/playback']?.singleClip; - - let loopButton = document.getElementById("loopButton"); - let singleClipButton = document.getElementById("singleClipButton"); - - if (loopState) { - loopButton.classList.add("activated"); - } else { - loopButton.classList.remove("activated"); - } - - if (singleClipState) { - singleClipButton.classList.add("activated"); - } else { - singleClipButton.classList.remove("activated"); - } - - // ========== Timecode ========== - - document.getElementById("timecodeLabel").innerHTML = parseTimecode(cameras[ci].propertyData['/transports/0/timecode']?.timecode); - - // ========== Presets Dropdown ========== - - if (!unsavedChanges.includes("presets")) { - - var presetsList = document.getElementById("presetsDropDown"); - - presetsList.innerHTML = ""; - - cameras[ci].propertyData['/presets']?.presets.forEach((presetItem) => { - let presetName = presetItem.split('.', 1); - - let textNode = document.createTextNode(presetName); - let optionNode = document.createElement("option"); - optionNode.setAttribute("name", "presetOption"+presetName); - optionNode.appendChild(textNode); - document.getElementById("presetsDropDown").appendChild(optionNode); + // 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); }); - - // ========== Active Preset ========== - - var presetsList = document.getElementById("presetsDropDown"); - - presetsList.childNodes.forEach((child) => { - if (child.nodeName == 'OPTION' && (child.value+".cset") == cameras[ci].propertyData['/presets/active']?.preset) { - child.selected=true - } else { - child.selected=false + dd.childNodes.forEach(child => { + if (child.nodeName === 'OPTION') { + child.selected = (child.value + '.cset') === cam.propertyData['/presets/active']?.preset; } - }) - + }); } - // ========== Iris ========== + // 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; - document.getElementById("irisRange").value = cameras[ci].propertyData['/lens/iris']?.normalised; - document.getElementById("apertureStopsLabel").innerHTML = cameras[ci].propertyData['/lens/iris']?.apertureStop.toFixed(1); - - // ========== Zoom ========== - - document.getElementById("zoomRange").value = cameras[ci].propertyData['/lens/zoom']?.normalised; - document.getElementById("zoomMMLabel").innerHTML = cameras[ci].propertyData['/lens/zoom']?.focalLength +"mm"; - - // ========== Focus ========== - - document.getElementById("focusRange").value = cameras[ci].propertyData['/lens/focus']?.normalised; - - // ========== ISO ========== - if (!unsavedChanges.includes("ISO")) { - if (cameras[ci].propertyData['/video/iso']) - document.getElementById("ISOInput").value = cameras[ci].propertyData['/video/iso']?.iso; - } - - // ========== GAIN ========== - - if (!unsavedChanges.includes("Gain")) { - let gainString = ""; - let gainInt = cameras[ci].propertyData['/video/gain']?.gain - - if (gainInt >= 0) { - gainString = "+"+gainInt+"db" - } else { - gainString = gainInt+"db" + // 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; } - - document.getElementById("gainSpan").innerHTML = gainString; - } - - // ========== WHITE BALANCE =========== - - if (!unsavedChanges.includes("WB")) { - document.getElementById("whiteBalanceSpan").innerHTML = cameras[ci].propertyData['/video/whiteBalance']?.whiteBalance+"K"; - } - - if (!unsavedChanges.includes("WBT")) { - document.getElementById("whiteBalanceTintSpan").innerHTML = cameras[ci].propertyData['/video/whiteBalanceTint']?.whiteBalanceTint; - } - - // =========== ND ============= - - if (!unsavedChanges.includes("ND")) { - if (cameras[ci].propertyData['/video/ndFilter']) { - document.getElementById("ndFilterSpan").innerHTML = cameras[ci].propertyData['/video/ndFilter']?.stop; - } else { - document.getElementById("ndFilterSpan").innerHTML = 0; - document.getElementById("ndFilterSpan").disabled = true; - } - } - - // ============ Shutter ===================== - - if (!unsavedChanges.includes("Shutter")) { - let shutterString = "SS" - let shutterObj = cameras[ci].propertyData['/video/shutter']; - - if (shutterObj?.shutterSpeed) { - shutterString = "1/"+shutterObj.shutterSpeed - } else if (shutterObj?.shutterAngle) { - var shangleString = (shutterObj.shutterAngle / 100).toFixed(1).toString() - if (shangleString.indexOf(".0") > 0) { - shutterString = parseFloat(shangleString).toFixed(0)+"°"; - } else { - shutterString = shangleString+"°"; - } - } - - document.getElementById("shutterSpan").innerHTML = shutterString; - } - - // =========== Auto Exposure Mode =========== - - if (!unsavedChanges.includes("AutoExposure")) { - let AEmodeSelect = document.getElementById("AEmodeDropDown"); - let AEtypeSelect = document.getElementById("AEtypeDropDown"); - - AEmodeSelect.value = cameras[ci].propertyData['/video/autoExposure']?.mode; - AEtypeSelect.value = cameras[ci].propertyData['/video/autoExposure']?.type; - } - - // =========== COLOR CORRECTION ============= - - // Lift - if (!unsavedChanges.includes("CC0")) { - let liftProps = cameras[ci].propertyData['/colorCorrection/lift']; - document.getElementsByClassName("CClumaLabel")[0].innerHTML = liftProps?.luma.toFixed(2); - document.getElementsByClassName("CCredLabel")[0].innerHTML = liftProps?.red.toFixed(2); - document.getElementsByClassName("CCgreenLabel")[0].innerHTML = liftProps?.green.toFixed(2); - document.getElementsByClassName("CCblueLabel")[0].innerHTML = liftProps?.blue.toFixed(2); - } - - // Gamma - if (!unsavedChanges.includes("CC1")) { - let gammaProps = cameras[ci].propertyData['/colorCorrection/gamma']; - document.getElementsByClassName("CClumaLabel")[1].innerHTML = gammaProps?.luma.toFixed(2); - document.getElementsByClassName("CCredLabel")[1].innerHTML = gammaProps?.red.toFixed(2); - document.getElementsByClassName("CCgreenLabel")[1].innerHTML = gammaProps?.green.toFixed(2); - document.getElementsByClassName("CCblueLabel")[1].innerHTML = gammaProps?.blue.toFixed(2); } // Gain - if (!unsavedChanges.includes("CC2")) { - let gainProps = cameras[ci].propertyData['/colorCorrection/gain']; - document.getElementsByClassName("CClumaLabel")[2].innerHTML = gainProps?.luma.toFixed(2); - document.getElementsByClassName("CCredLabel")[2].innerHTML = gainProps?.red.toFixed(2); - document.getElementsByClassName("CCgreenLabel")[2].innerHTML = gainProps?.green.toFixed(2); - document.getElementsByClassName("CCblueLabel")[2].innerHTML = gainProps?.blue.toFixed(2); + 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")) { - let offsetProps = cameras[ci].propertyData['/colorCorrection/offset']; - document.getElementsByClassName("CClumaLabel")[3].innerHTML = offsetProps?.luma.toFixed(2); - document.getElementsByClassName("CCredLabel")[3].innerHTML = offsetProps?.red.toFixed(2); - document.getElementsByClassName("CCgreenLabel")[3].innerHTML = offsetProps?.green.toFixed(2); - document.getElementsByClassName("CCblueLabel")[3].innerHTML = offsetProps?.blue.toFixed(2); + 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")) { - let constrastProps = cameras[ci].propertyData['/colorCorrection/contrast']; - document.getElementById("CCcontrastPivotRange").value = constrastProps?.pivot; - document.getElementById("CCcontrastPivotLabel").innerHTML = constrastProps?.pivot.toFixed(2); - document.getElementById("CCcontrastAdjustRange").value = constrastProps?.adjust; - document.getElementById("CCcontrastAdjustLabel").innerHTML = parseInt(constrastProps?.adjust * 50)+"%"; - } - - // Color - if (!unsavedChanges.includes("CC5")) { - let colorProps = cameras[ci].propertyData['/colorCorrection/color']; - document.getElementById("CChueRange").value = colorProps?.hue; - document.getElementById("CCcolorHueLabel").innerHTML = parseInt((colorProps?.hue + 1) * 180)+"°"; - - document.getElementById("CCsaturationRange").value = colorProps?.saturation; - document.getElementById("CCcolorSatLabel").innerHTML = parseInt(colorProps?.saturation * 50)+"%"; - - let lumaContributionProps = cameras[ci].propertyData['/colorCorrection/lumaContribution']; - document.getElementById("CClumaContributionRange").value = lumaContributionProps?.lumaContribution; - document.getElementById("CCcolorLCLabel").innerHTML = parseInt(lumaContributionProps?.lumaContribution * 100)+"%"; - } - - // ============ Footer Links =============== - document.getElementById("documentationLink").href = (cameras[ci].useHTTPS ? "https://" : "http://")+cameras[ci].hostname+"/control/documentation.html"; - document.getElementById("mediaManagerLink").href = (cameras[ci].useHTTPS ? "https://" : "http://")+cameras[ci].hostname; -} - - -// ============================================================================== - -// Called when the user changes tabs to a different camera -function switchCamera(index) { - if (cameras[ci]) { - cameras[ci].active = false; - } - - ci = index; - - // Reset the Controls - document.getElementById("allCamerasContainer").innerHTML = defaultControlsHTML; - - // Update the UI - - for (var i = 0; i < 8; i++) { - if (i == ci) { - document.getElementsByClassName("cameraSwitchLabel")[i].classList.add("selectedCam"); - } else { - document.getElementsByClassName("cameraSwitchLabel")[i].classList.remove("selectedCam"); + 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) + '%'; } } - document.getElementById("cameraNumberLabel").innerHTML = "CAM"+(ci+1); - document.getElementById("cameraName").innerHTML = "CAMERA NAME"; - document.getElementById("hostnameInput").value = localStorage.getItem("camerahostname_"+ci.toString()); - if ( localStorage.getItem("camerasecurity_"+ci.toString()) === 'true' ) { - document.getElementById("secureCheckbox").checked = true - } - if (cameras[ci]) { - cameras[ci].active = true; - } -} - -// For not-yet-implemented Color Correction UI -function setCCMode(mode) { - if (mode == 0) { - // Lift - - } else if (mode == 1) { - // Gamma - - } else { - // Gain - - } - - for (var i = 0; i < 3; i++) { - if (i == mode) { - document.getElementsByClassName("ccTabLabel")[i].classList.add("selectedTab"); - } else { - document.getElementsByClassName("ccTabLabel")[i].classList.remove("selectedTab"); + // 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; } -// Allows for changing WB/Tint displayed in the UI -function swapWBMode() { - if (WBMode == 0) { - // Balance - document.getElementById("WBLabel").innerHTML = "TINT"; - document.getElementById("WBValueContainer").classList.add("dNone"); - document.getElementById("WBTintValueContainer").classList.remove("dNone"); - - WBMode = 1; +// ===================================================================== +// 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 { - //Tint - document.getElementById("WBLabel").innerHTML = "BALANCE"; - document.getElementById("WBValueContainer").classList.remove("dNone"); - document.getElementById("WBTintValueContainer").classList.add("dNone"); - - WBMode = 0; + unsavedChanges.push('ISO'); } } -// Triggered by the button by those text boxes. Reads the info from the inputs and sends it to the camera. -function manualAPICall() { - const requestRadioGET = document.getElementById("requestTypeGET"); - - const requestEndpointText = document.getElementById("manualRequestEndpointLabel").value; - let requestData = ""; - - try { - requestData = JSON.parse(document.getElementById("manualRequestBodyLabel").value); - } catch (err) { - document.getElementById("manualRequestResponseP").innerHTML = err; - } - - const requestMethod = (requestRadioGET.checked ? "GET" : "PUT"); - const requestURL = cameras[ci].APIAddress+requestEndpointText; - - let response = sendRequest(requestMethod,requestURL,requestData); - - document.getElementById("manualRequestResponseP").innerHTML = JSON.stringify(response); +function ISOBlurHandler() { + unsavedChanges = unsavedChanges.filter(e => e !== 'ISO'); } -/* Control Calling Functions */ -/* Makes the HTML cleaner. */ +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() { - cameras[ci].PUTdata("/video/ndFilter",{stop: cameras[ci].propertyData['/video/ndFilter'].stop-2}); + if (!cameras[ci]) return; + cameras[ci].PUTdata('/video/ndFilter', { stop: (cameras[ci].propertyData['/video/ndFilter']?.stop ?? 0) - 2 }); } function increaseND() { - cameras[ci].PUTdata("/video/ndFilter",{stop: cameras[ci].propertyData['/video/ndFilter'].stop+2}); + 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() { - cameras[ci].PUTdata("/video/gain",{gain: cameras[ci].propertyData['/video/gain'].gain-2}); + if (!cameras[ci]) return; + cameras[ci].PUTdata('/video/gain', { gain: (cameras[ci].propertyData['/video/gain']?.gain ?? 0) - 2 }); } function increaseGain() { - cameras[ci].PUTdata("/video/gain",{gain: cameras[ci].propertyData['/video/gain'].gain+2}); + 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() { - let cam = cameras[ci]; - - if ('shutterSpeed' in cam.propertyData['/video/shutter']) { - cam.PUTdata("/video/shutter", {"shutterSpeed": cam.propertyData['/video/shutter'].shutterSpeed+10}); + 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}); + cam.PUTdata('/video/shutter', { shutterAngle: cam.propertyData['/video/shutter'].shutterAngle - 1000 }); } } function increaseShutter() { - let cam = cameras[ci]; - - if ('shutterSpeed' in cam.propertyData['/video/shutter']) { - cam.PUTdata("/video/shutter", {"shutterSpeed": cam.propertyData['/video/shutter'].shutterSpeed-10}); + 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}); + cam.PUTdata('/video/shutter', { shutterAngle: cam.propertyData['/video/shutter'].shutterAngle + 1000 }); } } function handleShutterInput() { - let inputString = document.getElementById("shutterSpan").innerHTML; - + const input = document.getElementById('shutterDisplay').textContent; if (event.key === 'Enter') { - let cam = cameras[ci]; - - if ('shutterSpeed' in cam.propertyData['/video/shutter']) { - if (inputString.indexOf("1/") >= 0) { - cam.PUTdata("/video/shutter", {"shutterSpeed" :parseInt(inputString.substring(2))}); - } else { - cam.PUTdata("/video/shutter", {"shutterSpeed" :parseInt(inputString)}); - } - + 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(inputString)*100)}); + cam.PUTdata('/video/shutter', { shutterAngle: parseInt(parseFloat(input) * 100) }); } - - unsavedChanges = unsavedChanges.filter((e) => {return e !== "Shutter"}); + 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() { - cameras[ci].PUTdata("/video/whiteBalance", {whiteBalance: cameras[ci].propertyData['/video/whiteBalance'].whiteBalance-50}); + if (!cameras[ci]) return; + cameras[ci].PUTdata('/video/whiteBalance', { whiteBalance: (cameras[ci].propertyData['/video/whiteBalance']?.whiteBalance ?? 5600) - 50 }); } function increaseWhiteBalance() { - cameras[ci].PUTdata("/video/whiteBalance", {whiteBalance: cameras[ci].propertyData['/video/whiteBalance'].whiteBalance+50}); + 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() { - cameras[ci].PUTdata("/video/whiteBalanceTint", {whiteBalanceTint: cameras[ci].propertyData['/video/whiteBalanceTint'].whiteBalanceTint-1}); + if (!cameras[ci]) return; + cameras[ci].PUTdata('/video/whiteBalanceTint', { whiteBalanceTint: (cameras[ci].propertyData['/video/whiteBalanceTint']?.whiteBalanceTint ?? 0) - 1 }); } function increaseWhiteBalanceTint() { - cameras[ci].PUTdata("/video/whiteBalanceTint", {whiteBalanceTint: cameras[ci].propertyData['/video/whiteBalanceTint'].whiteBalanceTint+1}); + 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() { - let selectedPreset = document.getElementById("presetsDropDown").value; + cameras[ci].PUTdata('/presets/active', { preset: document.getElementById('presetsDropDown').value + '.cset' }); + unsavedChanges = unsavedChanges.filter(e => e !== 'presets'); +} - cameras[ci].PUTdata("/presets/active", {preset: selectedPreset+".cset"}); +function AEmodeInputHandler() { + cameras[ci].PUTdata('/video/autoExposure', { + mode: document.getElementById('AEmodeDropDown').value, + type: document.getElementById('AEtypeDropDown').value, + }); + unsavedChanges = unsavedChanges.filter(e => e !== 'AutoExposure'); +} - unsavedChanges = unsavedChanges.filter((e) => {return e !== "presets"}); +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() { - let newHostname = document.getElementById("hostnameInput").value; - if (event.key === 'Enter') { - event.preventDefault; - unsavedChanges = unsavedChanges.filter((e) => {return e !== "Hostname"}); + event.preventDefault(); + unsavedChanges = unsavedChanges.filter(e => e !== 'Hostname'); initCamera(); } else { unsavedChanges.push('Hostname'); } } -function AEmodeInputHandler() { - let AEmode = document.getElementById("AEmodeDropDown").value; - let AEtype = document.getElementById("AEtypeDropDown").value; - - cameras[ci].PUTdata("/video/autoExposure", {mode: AEmode, type: AEtype}); - - unsavedChanges = unsavedChanges.filter((e) => {return e !== "AutoExposure"}); -} - -function ISOInputHandler() { - let ISOInput = document.getElementById("ISOInput"); - - if (event.key === 'Enter') { - event.preventDefault; - cameras[ci].PUTdata("/video/iso", {iso: parseInt(ISOInput.value)}) - unsavedChanges = unsavedChanges.filter((e) => {return e !== "ISO"}); - } else { - unsavedChanges.push('ISO'); - } -} - -// 0: lift, 1: gamma, 2: gain, 3: offset, 4: contrast, 5: color & LC -function CCInputHandler(which) { - if (event.key === 'Enter') { - event.preventDefault; - setCCFromUI(which); - } else { - unsavedChanges.push('CC'+which); - } -} - -function NDFilterInputHandler() { - if (event.key === 'Enter') { - event.preventDefault; - cameras[ci].PUTdata("/video/ndFilter", {stop: parseInt(document.getElementById("ndFilterSpan").innerHTML)}) - unsavedChanges = unsavedChanges.filter((e) => {return e !== "ND"}); - } else { - unsavedChanges.push('ND'); - } -} - -function GainInputHandler() { - if (event.key === 'Enter') { - event.preventDefault; - cameras[ci].PUTdata("/video/gain", {gain: parseInt(document.getElementById("gainSpan").innerHTML)}) - unsavedChanges = unsavedChanges.filter((e) => {return e !== "Gain"}); - } else { - unsavedChanges.push('Gain'); - } -} - -function WBInputHandler() { - if (event.key === 'Enter') { - event.preventDefault; - cameras[ci].PUTdata("/video/whiteBalance", {whiteBalance: parseInt(document.getElementById("whiteBalanceSpan").innerHTML)}) - unsavedChanges = unsavedChanges.filter((e) => {return e !== "WB"}); - } else { - unsavedChanges.push('WB'); - } -} - -function WBTInputHandler() { - if (event.key === 'Enter') { - event.preventDefault; - cameras[ci].PUTdata("/video/whiteBalanceTint", {whiteBalanceTint: parseInt(document.getElementById("whiteBalanceTintSpan").innerHTML)}) - unsavedChanges = unsavedChanges.filter((e) => {return e !== "WBT"}); - } else { - unsavedChanges.push('WBT'); - } -} - -// 0: lift, 1: gamma, 2: gain, 3: offset -function setCCFromUI(which) { - if (which < 4) { - var lumaFloat = parseFloat(document.getElementsByClassName("CClumaLabel")[which].innerHTML); - var redFloat = parseFloat(document.getElementsByClassName("CCredLabel")[which].innerHTML); - var greenFloat = parseFloat(document.getElementsByClassName("CCgreenLabel")[which].innerHTML); - var blueFloat = parseFloat(document.getElementsByClassName("CCblueLabel")[which].innerHTML); - - var ccobject = {"red": redFloat, "green": greenFloat, "blue": blueFloat, "luma": lumaFloat}; - } - - if (which == 0) { - cameras[ci].PUTdata("/colorCorrection/lift", ccobject); - } else if (which == 1) { - cameras[ci].PUTdata("/colorCorrection/gamma", ccobject); - } else if (which == 2) { - cameras[ci].PUTdata("/colorCorrection/gain", ccobject); - } else if (which == 3) { - cameras[ci].PUTdata("/colorCorrection/offset", ccobject); - } else if (which == 4) { - let pivotFloat = parseFloat(document.getElementById("CCcontrastPivotLabel").innerHTML); - let adjustInt = parseInt(document.getElementById("CCcontrastAdjustLabel").innerHTML); - - let adjustFloat = adjustInt/50.0; - - cameras[ci].PUTdata("/colorCorrection/contrast", {pivot: pivotFloat, adjust: adjustFloat}); - } else { - let hueInt = parseInt(document.getElementById("CCcolorHueLabel").innerHTML); - let satInt = parseInt(document.getElementById("CCcolorSatLabel").innerHTML); - let lumCoInt = parseInt(document.getElementById("CCcolorLCLabel").innerHTML); - - let hueFloat = (hueInt/180.0) - 1.0; - let satFloat = satInt/50.0; - let lumCoFloat = lumCoInt/100.0; - - cameras[ci].PUTdata("/colorCorrection/color", {hue: hueFloat, saturation: satFloat}); - cameras[ci].PUTdata("/colorCorrection/lumaContribution", {lumaContribution: lumCoFloat}); - } - - unsavedChanges = unsavedChanges.filter((e) => {return !e.includes("CC"+which)}); -} - -// Reset Color Correction Values -// 0: lift, 1: gamma, 2: gain, 3: offset, 4: contrast, 5: color & LC -function resetCC(which) { - if (which == 0) { - cameras[ci].PUTdata("/colorCorrection/lift", {"red": 0.0, "green": 0.0, "blue": 0.0, "luma": 0.0}); - } else if (which == 1) { - cameras[ci].PUTdata("/colorCorrection/gamma", {"red": 0.0, "green": 0.0, "blue": 0.0, "luma": 0.0}); - } else if (which == 2) { - cameras[ci].PUTdata("/colorCorrection/gain", {"red": 1.0, "green": 1.0, "blue": 1.0, "luma": 1.0}); - } else if (which == 3) { - cameras[ci].PUTdata("/colorCorrection/offset", {"red": 0.0, "green": 0.0, "blue": 0.0, "luma": 0.0}); - } else if (which == 4) { - cameras[ci].PUTdata("/colorCorrection/contrast", {"pivot": 0.5, "adjust": 1.0}); - } else if (which == 5) { - cameras[ci].PUTdata("/colorCorrection/color", {"hue": 0.0, "saturation": 1.0}); - cameras[ci].PUTdata("/colorCorrection/lumaContribution", {"lumaContribution": 1.0}); - } - - unsavedChanges = unsavedChanges.filter((e) => {return !e.includes("CC"+which)}); -} - -// Triggered by the Loop and Single Clip buttons function loopHandler(callerString) { - let playbackState = cameras[ci].propertyData['/transports/0/playback']; - - if (callerString === "Loop") { + const playbackState = cameras[ci].propertyData['/transports/0/playback']; + if (callerString === 'Loop') { playbackState.loop = !playbackState.loop; - } else if (callerString === "Single Clip") { + } else if (callerString === 'Single Clip') { playbackState.singleClip = !playbackState.singleClip; } - - cameras[ci].PUTdata("/transports/0/playback", playbackState); + cameras[ci].PUTdata('/transports/0/playback', playbackState); } -/* Helper Functions */ +// ===================================================================== +// Helpers +// ===================================================================== + function parseTimecode(timecodeBCD) { - let noDropFrame = timecodeBCD & 0b01111111111111111111111111111111; // The first bit of the timecode is 1 if "Drop Frame Timecode" is on. We don't want to include that in the display. - let decimalTCInt = parseInt(noDropFrame.toString(16), 10); // Convert the BCD number into base ten - let decimalTCString = decimalTCInt.toString().padStart(8, '0'); // Convert the base ten number to a string eight characters long - let finalTCString = decimalTCString.match(/.{1,2}/g).join(':'); // Put colons between every two characters - return finalTCString; + 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(':'); }