From 48db3cbaf4c0044d88398dd7d7d4a462ec5746aa Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 25 Jun 2026 20:34:14 +0800 Subject: [PATCH] feat(#16): optional exposure/framerate lock (manual exposure) Auto-exposure raises frame time past the 33ms budget in low light, halving fps to ~15. Add an optional control to pin the camera to Manual exposure so framerate holds steady, at the cost of a darker image. Off by default. Native: worker queries IAMCameraControl off the reader's media source; when the camera supports Manual exposure it maps a normalized 0..1 value onto the camera range (snapped to stepping) and pushes on change; OFF restores Auto. No-ops when unsupported; restores Auto on shutdown. ABI: CosParams += exposure_lock_enabled + exposure_value; CosStatus += exposure_supported (support is per open camera, known only while running, so surfaced via the polled status not the pre-start capability probe). C#: ShimParams/ShimStatus + PInvoke mirrors; EffectSettings persistence; VM observable ExposureLock/ExposureValue/ExposureSupported with live push + config round-trip; XAML toggle + brightness slider, greyed until running and supported. The native camControl==null check is the authoritative gate (not a managed ApplyParams gate, which would force exposure off at Start before the first status poll and break persisted-on exposure). Co-Authored-By: Claude Opus 4.8 (1M context) --- native/shim/capture.cpp | 66 +++++++++++++++++++ native/shim/capture.h | 7 ++ native/shim/shim.cpp | 2 + native/shim/shim.h | 3 + src/CameraOnScreen.App/MainWindow.xaml | 10 +++ src/CameraOnScreen.App/MainWindow.xaml.cs | 4 ++ src/CameraOnScreen.App/Native/PInvokeShim.cs | 8 ++- src/CameraOnScreen.Core/Config/Models.cs | 2 + src/CameraOnScreen.Core/Native/Contracts.cs | 7 +- .../ViewModels/MainViewModel.cs | 14 +++- .../ViewModels/MainViewModelTests.cs | 27 ++++++++ 11 files changed, 146 insertions(+), 4 deletions(-) diff --git a/native/shim/capture.cpp b/native/shim/capture.cpp index 3bd034d..9fbf3af 100644 --- a/native/shim/capture.cpp +++ b/native/shim/capture.cpp @@ -15,6 +15,7 @@ #include #include #include +#include // IAMCameraControl, CameraControl_Exposure, CameraControl_Flags_Manual/_Auto // COM smart-pointer-free RAII via a tiny helper to avoid pulling in / . namespace { @@ -56,6 +57,10 @@ struct CaptureState { std::mutex srErrMtx; // leaf lock std::string srError; + std::atomic exposureLockEnabled{false}; // set by UI thread, read by worker + std::atomic exposureValue{0.0}; // 0..1 normalized, set by UI thread + std::atomic exposureSupported{false}; // set by worker once camera range is known + std::atomic framesProduced{0}; // bumped by worker after each published frame FpsCounter fpsCounter; // read only by MeasuredFps (status-poll thread) }; @@ -328,9 +333,53 @@ void Capture::WorkerLoop() { return; } + // Camera exposure control (issue #16). The device source behind the reader may expose + // IAMCameraControl; if it supports Manual exposure we can lock it to a fixed short value + // so framerate holds steady in low light. Held worker-local (COM apartment affinity), + // released before the reader. camControl stays null when unsupported -> feature greys out. + IAMCameraControl* camControl = nullptr; + long expMin = 0, expMax = 0, expStep = 1, expDef = 0, expCaps = 0; + { + IMFMediaSource* src = nullptr; + if (SUCCEEDED(reader->GetServiceForStream( + static_cast(MF_SOURCE_READER_MEDIASOURCE), GUID_NULL, IID_PPV_ARGS(&src))) + && src) { + if (SUCCEEDED(src->QueryInterface(IID_PPV_ARGS(&camControl))) && camControl) { + if (SUCCEEDED(camControl->GetRange(CameraControl_Exposure, + &expMin, &expMax, &expStep, &expDef, &expCaps)) + && (expCaps & CameraControl_Flags_Manual) && expStep > 0 && expMax > expMin) { + g_state.exposureSupported.store(true, std::memory_order_release); + } else { + SafeRelease(camControl); // no usable manual-exposure range + } + } + SafeRelease(src); + } + } + bool expAppliedEnabled = false; // last state pushed to the camera (starts in Auto) + double expAppliedValue = -1.0; // sentinel forces the first manual push + std::vector scratch; while (!g_state.stopRequested.load(std::memory_order_acquire)) { + // Apply exposure lock when the request changed (only touches the camera on change). + if (camControl) { + const bool expWant = g_state.exposureLockEnabled.load(std::memory_order_acquire); + const double expVal = g_state.exposureValue.load(std::memory_order_acquire); + if (expWant != expAppliedEnabled || (expWant && expVal != expAppliedValue)) { + if (expWant) { + const double t = expVal < 0.0 ? 0.0 : (expVal > 1.0 ? 1.0 : expVal); + long raw = expMin + static_cast(t * (expMax - expMin) + 0.5); + raw = expMin + ((raw - expMin) / expStep) * expStep; // snap to stepping delta + camControl->Set(CameraControl_Exposure, raw, CameraControl_Flags_Manual); + } else { + camControl->Set(CameraControl_Exposure, expDef, CameraControl_Flags_Auto); + } + expAppliedEnabled = expWant; + expAppliedValue = expVal; + } + } + DWORD streamIndex = 0, flags = 0; LONGLONG timestamp = 0; IMFSample* sample = nullptr; @@ -469,6 +518,13 @@ void Capture::WorkerLoop() { } } + // Restore auto-exposure on shutdown so the camera isn't left pinned to manual for + // other apps, then release the control before the reader. + if (camControl) { + camControl->Set(CameraControl_Exposure, expDef, CameraControl_Flags_Auto); + SafeRelease(camControl); + } + // Tear down all effects before releasing the reader (thread-affinity requirement). superRes.Stop(); eyeContact.Stop(); @@ -519,6 +575,8 @@ void Capture::Stop() { } g_state.superResActive.store(false, std::memory_order_release); { std::lock_guard e(g_state.srErrMtx); g_state.srError.clear(); } + // Exposure support is per open camera; clear it so a stopped session reads "unsupported". + g_state.exposureSupported.store(false, std::memory_order_release); } bool Capture::LatestFrame(std::vector& out, int& w, int& h) { @@ -574,6 +632,14 @@ std::string Capture::SuperResError() const { std::lock_guard e(g_state.srErrMtx); return g_state.srError; } +void Capture::SetExposureLock(bool enabled, double value) { + g_state.exposureValue.store(value, std::memory_order_release); + g_state.exposureLockEnabled.store(enabled, std::memory_order_release); +} +bool Capture::ExposureSupported() const { + return g_state.exposureSupported.load(std::memory_order_acquire); +} + Capture::~Capture() { Stop(); } diff --git a/native/shim/capture.h b/native/shim/capture.h index f0c8613..eab2e0c 100644 --- a/native/shim/capture.h +++ b/native/shim/capture.h @@ -48,6 +48,13 @@ class Capture { bool SuperResActive() const; std::string SuperResError() const; + // Locks camera exposure to Manual at a normalized 0..1 value (mapped onto the camera's + // IAMCameraControl exposure range) so framerate holds steady in low light; disabled + // restores Auto. Thread-safe; worker applies on change. No-op if the camera lacks + // manual-exposure support. + void SetExposureLock(bool enabled, double value); + bool ExposureSupported() const; // true once an open camera reported manual-exposure support + // Measured frames-per-second over a rolling window (status polling). Thread-safe to // call from the single status-poll thread; 0 until the first interval elapses. double MeasuredFps() const; diff --git a/native/shim/shim.cpp b/native/shim/shim.cpp index 104130e..d65ac94 100644 --- a/native/shim/shim.cpp +++ b/native/shim/shim.cpp @@ -66,6 +66,7 @@ COS_API void cos_set_params(const CosParams* p) { g_capture.SetMatteParams(p->green_screen_expand, p->green_screen_feather); g_capture.SetEyeContact(p->eye_contact_enabled != 0); g_capture.SetSuperRes(p->super_res_enabled != 0, p->super_res_quality_level, p->super_res_scale); + g_capture.SetExposureLock(p->exposure_lock_enabled != 0, p->exposure_value); } COS_API void cos_start(void) { g_capture.Start(g_cameraId); g_running = true; } @@ -78,6 +79,7 @@ COS_API void cos_get_status(CosStatus* out) { out->fps = g_running ? g_capture.MeasuredFps() : 0.0; out->green_screen_active = g_capture.GreenScreenActive() ? 1 : 0; out->eye_contact_active = g_capture.EyeContactActive() ? 1 : 0; + out->exposure_supported = g_capture.ExposureSupported() ? 1 : 0; std::string err = g_capture.GreenScreenError(); if (err.empty()) err = g_capture.EyeContactError(); if (err.empty()) err = g_capture.SuperResError(); diff --git a/native/shim/shim.h b/native/shim/shim.h index 83cad75..86e66ed 100644 --- a/native/shim/shim.h +++ b/native/shim/shim.h @@ -12,6 +12,7 @@ typedef struct { int gaze; // 0 Unknown,1 OnCamera,2 Redirected,3 RealEyes int green_screen_active; int eye_contact_active; + int exposure_supported; // 1 if the open camera exposes manual exposure (known only while running) char error[256]; // empty string = no error } CosStatus; @@ -26,6 +27,8 @@ typedef struct { int super_res_enabled; int super_res_scale; // 0=off, 15=1.5x, 20=2x (upscale modes only) int super_res_quality_level; // VSR QualityLevel: 1-4 upscale, 8-11 denoise, 12-15 deblur + int exposure_lock_enabled; // 1 = manual exposure (locks fps); 0 = auto (default) + double exposure_value; // 0..1 normalized; native maps to camera's IAMCameraControl range } CosParams; typedef struct { diff --git a/src/CameraOnScreen.App/MainWindow.xaml b/src/CameraOnScreen.App/MainWindow.xaml index cdd8f27..c4a79bd 100644 --- a/src/CameraOnScreen.App/MainWindow.xaml +++ b/src/CameraOnScreen.App/MainWindow.xaml @@ -54,6 +54,16 @@ Visibility="{x:Bind NotAvailableVisibility, Mode=OneWay}" Foreground="{ThemeResource SystemFillColorCautionBrush}"/> + + + + available && modeIndex != 0; + // Exposure slider is live only when the camera supports manual exposure AND the lock is on. + // x:Bind re-runs this when either ExposureSupported or ExposureLock raises PropertyChanged. + public static bool ExposureSliderEnabled(bool supported, bool locked) => supported && locked; + public string StatusLine => Vm.IsRunning ? $"Running — {Vm.Fps:F0} fps" : "Stopped"; diff --git a/src/CameraOnScreen.App/Native/PInvokeShim.cs b/src/CameraOnScreen.App/Native/PInvokeShim.cs index bd7c536..f0e2bfb 100644 --- a/src/CameraOnScreen.App/Native/PInvokeShim.cs +++ b/src/CameraOnScreen.App/Native/PInvokeShim.cs @@ -13,6 +13,7 @@ private struct CosStatus { public int running; public double fps; public int gaze; public int green_screen_active; public int eye_contact_active; + public int exposure_supported; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string error; } @@ -27,6 +28,8 @@ private struct CosParams public int super_res_enabled; public int super_res_scale; public int super_res_quality_level; + public int exposure_lock_enabled; + public double exposure_value; } [StructLayout(LayoutKind.Sequential)] @@ -84,6 +87,8 @@ public void SetParams(ShimParams p) super_res_enabled = p.SuperResEnabled ? 1 : 0, super_res_scale = p.SuperResScale, super_res_quality_level = p.SuperResQualityLevel, + exposure_lock_enabled = p.ExposureLockEnabled ? 1 : 0, + exposure_value = p.ExposureValue, }; cos_set_params(ref native); } @@ -100,7 +105,8 @@ public ShimStatus GetStatus() Running: s.running != 0, Fps: s.fps, Gaze: (GazeState)s.gaze, GreenScreenActive: s.green_screen_active != 0, EyeContactActive: s.eye_contact_active != 0, - Error: string.IsNullOrEmpty(s.error) ? null : s.error); + Error: string.IsNullOrEmpty(s.error) ? null : s.error, + ExposureSupported: s.exposure_supported != 0); } public bool TryGetFrame(byte[] buffer, out int width, out int height) diff --git a/src/CameraOnScreen.Core/Config/Models.cs b/src/CameraOnScreen.Core/Config/Models.cs index 112f392..12aa5e4 100644 --- a/src/CameraOnScreen.Core/Config/Models.cs +++ b/src/CameraOnScreen.Core/Config/Models.cs @@ -31,6 +31,8 @@ public sealed record EffectSettings public double EyeContactLookAwayRange { get; init; } = 0.5; public int SuperResMode { get; init; } // 0=Off, 1=Denoise, 2=Deblur public int SuperResQuality { get; init; } // 0=Low, 1=Med, 2=High, 3=Ultra + public bool ExposureLock { get; init; } // #16: lock camera exposure (steady fps), off by default + public double ExposureValue { get; init; } = 0.5; // 0..1 normalized exposure when locked } public sealed record HotkeyBinding diff --git a/src/CameraOnScreen.Core/Native/Contracts.cs b/src/CameraOnScreen.Core/Native/Contracts.cs index 73485b2..05c7cd7 100644 --- a/src/CameraOnScreen.Core/Native/Contracts.cs +++ b/src/CameraOnScreen.Core/Native/Contracts.cs @@ -17,7 +17,8 @@ public readonly record struct ShimStatus( GazeState Gaze, bool GreenScreenActive, bool EyeContactActive, - string? Error); + string? Error, + bool ExposureSupported = false); // true while a running camera exposes manual exposure (#16) public sealed record ShimParams( string? CameraId, @@ -29,7 +30,9 @@ public sealed record ShimParams( double EyeContactLookAwayRange, bool SuperResEnabled = false, int SuperResScale = 0, // 0=off, 15=1.5x, 20=2x (upscale modes only) - int SuperResQualityLevel = 1); // VSR QualityLevel: 1-4 upscale, 8-11 denoise, 12-15 deblur + int SuperResQualityLevel = 1, // VSR QualityLevel: 1-4 upscale, 8-11 denoise, 12-15 deblur + bool ExposureLockEnabled = false, // lock camera exposure to Manual (#16) so fps holds steady + double ExposureValue = 0.0); // 0..1 normalized exposure when locked (native maps to camera range) public interface INativeShim : IDisposable { diff --git a/src/CameraOnScreen.Core/ViewModels/MainViewModel.cs b/src/CameraOnScreen.Core/ViewModels/MainViewModel.cs index 58e57dd..2fd8fb4 100644 --- a/src/CameraOnScreen.Core/ViewModels/MainViewModel.cs +++ b/src/CameraOnScreen.Core/ViewModels/MainViewModel.cs @@ -69,6 +69,9 @@ public async Task ProbeCapabilitiesAsync() [ObservableProperty] private bool eyeContactAvailable; [ObservableProperty] private string eyeContactDetail = "Checking effect availability…"; [ObservableProperty] private bool superResAvailable; + [ObservableProperty] private bool exposureLock; // #16: lock exposure to hold fps + [ObservableProperty] private double exposureValue = 0.5; // 0..1 normalized; only meaningful when locked + [ObservableProperty] private bool exposureSupported; // camera exposes manual exposure (status poll, while running) [ObservableProperty] private bool isRunning; [ObservableProperty] private bool locked; [ObservableProperty] private bool clickThrough; @@ -98,6 +101,8 @@ public void LoadFrom(AppConfig config) // throws ArgumentException when the binding sets SelectedIndex → startup crash. SuperResModeIndex = Math.Clamp(config.Effects.SuperResMode, 0, 2); SuperResQualityIndex = Math.Clamp(config.Effects.SuperResQuality, 0, 3); + ExposureLock = config.Effects.ExposureLock; + ExposureValue = Math.Clamp(config.Effects.ExposureValue, 0.0, 1.0); Locked = config.Overlay.Locked; ClickThrough = config.Overlay.ClickThrough; Mirror = config.Overlay.Mirror; @@ -127,6 +132,8 @@ public void LoadFrom(AppConfig config) EyeContactLookAwayRange = EyeContactLookAwayRange, SuperResMode = SuperResModeIndex, SuperResQuality = SuperResQualityIndex, + ExposureLock = ExposureLock, + ExposureValue = ExposureValue, }, Hotkeys = _hotkeys }; @@ -144,6 +151,8 @@ public void LoadFrom(AppConfig config) partial void OnEyeContactLookAwayRangeChanged(double value) => ApplyLiveParams(); partial void OnSuperResModeIndexChanged(int value) => ApplyLiveParams(); partial void OnSuperResQualityIndexChanged(int value) => ApplyLiveParams(); + partial void OnExposureLockChanged(bool value) => ApplyLiveParams(); + partial void OnExposureValueChanged(double value) => ApplyLiveParams(); private void ApplyLiveParams() { @@ -160,7 +169,9 @@ private void ApplyLiveParams() EyeContactLookAwayRange: EyeContactLookAwayRange, SuperResEnabled: SuperResModeIndex != 0, SuperResScale: 0, // denoise/deblur: out == in, no upscale - SuperResQualityLevel: QualityLevelFor(SuperResModeIndex, SuperResQualityIndex)); + SuperResQualityLevel: QualityLevelFor(SuperResModeIndex, SuperResQualityIndex), + ExposureLockEnabled: ExposureLock, + ExposureValue: ExposureValue); public void OnStatus(ShimStatus s) { @@ -168,6 +179,7 @@ public void OnStatus(ShimStatus s) Fps = s.Fps; Gaze = s.Gaze; StatusError = s.Error; + ExposureSupported = s.ExposureSupported; } public void Dispose() diff --git a/tests/CameraOnScreen.Core.Tests/ViewModels/MainViewModelTests.cs b/tests/CameraOnScreen.Core.Tests/ViewModels/MainViewModelTests.cs index e86993d..2b7b43b 100644 --- a/tests/CameraOnScreen.Core.Tests/ViewModels/MainViewModelTests.cs +++ b/tests/CameraOnScreen.Core.Tests/ViewModels/MainViewModelTests.cs @@ -107,6 +107,33 @@ public void BuildParams_reflects_vm_state() Assert.Equal(0.2, p.GreenScreenFeather); } + [Fact] + public void Exposure_lock_round_trips_through_params_config_and_status() + { + // #16: BuildParams carries the lock + value; ToAppConfig→LoadFrom persists them; and the + // status poll surfaces per-camera support for UI greying. + var vm = Build(GpuTier.Rtx, out _); + vm.ExposureLock = true; + vm.ExposureValue = 0.25; + var p = vm.BuildParams(); + Assert.True(p.ExposureLockEnabled); + Assert.Equal(0.25, p.ExposureValue); + + var cfg = vm.ToAppConfig(0, 0, 320, 240); + Assert.True(cfg.Effects.ExposureLock); + Assert.Equal(0.25, cfg.Effects.ExposureValue); + + var fresh = Build(GpuTier.Rtx, out _); + fresh.LoadFrom(cfg); + Assert.True(fresh.ExposureLock); + Assert.Equal(0.25, fresh.ExposureValue); + + Assert.False(fresh.ExposureSupported); + fresh.OnStatus(new ShimStatus(true, 30, GazeState.OnCamera, false, false, null, + ExposureSupported: true)); + Assert.True(fresh.ExposureSupported); + } + [Fact] public void Start_sets_running_and_OnStatus_updates_fps() {