Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions native/shim/capture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include <mfreadwrite.h>
#include <mferror.h>
#include <shlwapi.h>
#include <dshow.h> // IAMCameraControl, CameraControl_Exposure, CameraControl_Flags_Manual/_Auto

// COM smart-pointer-free RAII via a tiny helper to avoid pulling in <wrl> / <comdef>.
namespace {
Expand Down Expand Up @@ -56,6 +57,10 @@ struct CaptureState {
std::mutex srErrMtx; // leaf lock
std::string srError;

std::atomic<bool> exposureLockEnabled{false}; // set by UI thread, read by worker
std::atomic<double> exposureValue{0.0}; // 0..1 normalized, set by UI thread
std::atomic<bool> exposureSupported{false}; // set by worker once camera range is known

std::atomic<uint64_t> framesProduced{0}; // bumped by worker after each published frame
FpsCounter fpsCounter; // read only by MeasuredFps (status-poll thread)
};
Expand Down Expand Up @@ -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<DWORD>(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<uint8_t> 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<long>(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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -519,6 +575,8 @@ void Capture::Stop() {
}
g_state.superResActive.store(false, std::memory_order_release);
{ std::lock_guard<std::mutex> 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<uint8_t>& out, int& w, int& h) {
Expand Down Expand Up @@ -574,6 +632,14 @@ std::string Capture::SuperResError() const {
std::lock_guard<std::mutex> 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();
}
Expand Down
7 changes: 7 additions & 0 deletions native/shim/capture.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions native/shim/shim.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions native/shim/shim.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions src/CameraOnScreen.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@
Visibility="{x:Bind NotAvailableVisibility, Mode=OneWay}"
Foreground="{ThemeResource SystemFillColorCautionBrush}"/>

<!-- Exposure lock (#16): pin the camera to Manual exposure so framerate holds in
low light. Greyed until running AND the camera reports manual-exposure support
(known only after Start, via status poll). Slider trades brightness vs fps. -->
<ToggleSwitch Header="Lock exposure (steady FPS)"
IsEnabled="{x:Bind Vm.ExposureSupported, Mode=OneWay}"
IsOn="{x:Bind Vm.ExposureLock, Mode=TwoWay}"/>
<Slider Header="Exposure" Minimum="0" Maximum="1" StepFrequency="0.05"
IsEnabled="{x:Bind local:MainWindow.ExposureSliderEnabled(Vm.ExposureSupported, Vm.ExposureLock), Mode=OneWay}"
Value="{x:Bind Vm.ExposureValue, Mode=TwoWay}"/>

<ToggleSwitch Header="Lock overlay"
IsOn="{x:Bind Vm.Locked, Mode=TwoWay}"/>
<ToggleSwitch Header="Click-through"
Expand Down
4 changes: 4 additions & 0 deletions src/CameraOnScreen.App/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ private void Save()
// argument (SuperResAvailable / SuperResModeIndex) raises PropertyChanged. Mode 0 = Off, 1 = Upscale.
public static bool QualityEnabled(bool available, int modeIndex) => 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";

Expand Down
8 changes: 7 additions & 1 deletion src/CameraOnScreen.App/Native/PInvokeShim.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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)]
Expand Down Expand Up @@ -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);
}
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/CameraOnScreen.Core/Config/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/CameraOnScreen.Core/Native/Contracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
{
Expand Down
14 changes: 13 additions & 1 deletion src/CameraOnScreen.Core/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -127,6 +132,8 @@ public void LoadFrom(AppConfig config)
EyeContactLookAwayRange = EyeContactLookAwayRange,
SuperResMode = SuperResModeIndex,
SuperResQuality = SuperResQualityIndex,
ExposureLock = ExposureLock,
ExposureValue = ExposureValue,
},
Hotkeys = _hotkeys
};
Expand All @@ -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()
{
Expand All @@ -160,14 +169,17 @@ 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)
{
IsRunning = s.Running;
Fps = s.Fps;
Gaze = s.Gaze;
StatusError = s.Error;
ExposureSupported = s.ExposureSupported;
}

public void Dispose()
Expand Down
27 changes: 27 additions & 0 deletions tests/CameraOnScreen.Core.Tests/ViewModels/MainViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading