Skip to content

Commit f082915

Browse files
committed
feat: add HDR and Webcam support
1 parent efdcdcb commit f082915

4 files changed

Lines changed: 221 additions & 10 deletions

File tree

src/ObsKit.NET/Core/ObsConfiguration.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,18 @@ public sealed class VideoSettings
223223
/// </summary>
224224
public string? GraphicsModule { get; set; }
225225

226+
/// <summary>
227+
/// SDR white level in nits. Used for HDR tone-mapping of SDR sources and HDR metadata.
228+
/// Applied via obs_set_video_levels after each successful video reset. OBS default is 300.
229+
/// </summary>
230+
public float SdrWhiteLevel { get; set; } = 300f;
231+
232+
/// <summary>
233+
/// HDR nominal peak level in nits. Drives the HDR MaxCLL / mastering-display luminance
234+
/// metadata written by encoders and the recording muxer. OBS default is 1000.
235+
/// </summary>
236+
public float HdrNominalPeakLevel { get; set; } = 1000f;
237+
226238
/// <summary>
227239
/// Sets both base and output resolution.
228240
/// </summary>
@@ -265,6 +277,36 @@ public VideoSettings Fps(uint numerator, uint denominator = 1)
265277
return this;
266278
}
267279

280+
/// <summary>
281+
/// Configures the canvas for HDR: 10-bit P010 with a Rec.2100 colorspace (PQ by default)
282+
/// and limited range, plus the SDR-white / HDR-peak levels used for tone-mapping and metadata.
283+
/// Requires a 10-bit-capable HEVC or AV1 encoder; H.264/x264 cannot encode this.
284+
/// </summary>
285+
public VideoSettings Hdr(float nominalPeakNits = 1000f, float sdrWhiteNits = 300f, VideoColorspace colorspace = VideoColorspace.CS2100PQ)
286+
{
287+
Format = VideoFormat.P010;
288+
Colorspace = colorspace;
289+
Range = VideoRangeType.Partial;
290+
HdrNominalPeakLevel = nominalPeakNits;
291+
SdrWhiteLevel = sdrWhiteNits;
292+
return this;
293+
}
294+
295+
/// <summary>
296+
/// Configures the canvas for SDR (8-bit NV12, default colorspace/range). This is the
297+
/// default state; call it explicitly to undo a previous <see cref="Hdr"/> configuration
298+
/// because the underlying settings object is reused across resets.
299+
/// </summary>
300+
public VideoSettings Sdr()
301+
{
302+
Format = VideoFormat.NV12;
303+
Colorspace = VideoColorspace.Default;
304+
Range = VideoRangeType.Default;
305+
SdrWhiteLevel = 300f;
306+
HdrNominalPeakLevel = 1000f;
307+
return this;
308+
}
309+
268310
/// <summary>
269311
/// Sets the GPU adapter index to use.
270312
/// </summary>

src/ObsKit.NET/Native/Interop/ObsCore.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,28 @@ private static partial byte obs_startup_native(
8383
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
8484
internal static partial VideoHandle obs_get_video();
8585

86+
/// <summary>
87+
/// Sets the SDR white level and HDR nominal peak level (nits). Call after a successful
88+
/// <see cref="obs_reset_video"/>; drives HDR tone-mapping and HDR metadata.
89+
/// </summary>
90+
[LibraryImport(Lib, EntryPoint = "obs_set_video_levels")]
91+
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
92+
internal static partial void obs_set_video_levels(float sdrWhiteLevel, float hdrNominalPeakLevel);
93+
94+
/// <summary>
95+
/// Gets the SDR white level in nits (returns 300 if no video).
96+
/// </summary>
97+
[LibraryImport(Lib, EntryPoint = "obs_get_video_sdr_white_level")]
98+
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
99+
internal static partial float obs_get_video_sdr_white_level();
100+
101+
/// <summary>
102+
/// Gets the HDR nominal peak level in nits (returns 1000 if no video).
103+
/// </summary>
104+
[LibraryImport(Lib, EntryPoint = "obs_get_video_hdr_nominal_peak_level")]
105+
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
106+
internal static partial float obs_get_video_hdr_nominal_peak_level();
107+
86108
#endregion
87109

88110
#region Raw Video Callbacks

src/ObsKit.NET/ObsContext.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ private void ResetVideo(bool shutdownOnFailure = false)
190190
ObsCore.obs_shutdown();
191191
throw new ObsVideoResetException(result);
192192
}
193+
194+
// OBS clears these on every obs_reset_video, so re-apply them after a successful reset.
195+
ObsCore.obs_set_video_levels(_config.Video.SdrWhiteLevel, _config.Video.HdrNominalPeakLevel);
193196
}
194197
finally
195198
{

src/ObsKit.NET/Sources/WebcamCapture.cs

Lines changed: 154 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Runtime.InteropServices;
2+
using System.Runtime.InteropServices.ComTypes;
13
using ObsKit.NET.Core;
24

35
namespace ObsKit.NET.Sources;
@@ -106,9 +108,12 @@ public WebcamCapture(string name, string? deviceId = null)
106108
}
107109

108110
/// <summary>
109-
/// Enumerates the available video capture devices on the system. This works by creating
110-
/// a temporary dshow_input source and asking OBS to populate its property list, which is
111-
/// the same code path the OBS UI uses for the device dropdown.
111+
/// Enumerates the available video capture devices on the system. On Windows this reads
112+
/// the DirectShow device monikers via <c>ICreateDevEnum</c> / <c>IPropertyBag</c>, which
113+
/// only touches the registry-backed moniker — it never calls <c>CoCreateInstance</c> on
114+
/// the underlying filter. That avoids loading third-party DShow filter DLLs (e.g. Meta
115+
/// Quest's <c>magicdsfilterQuest3.dll</c>) that crash when instantiated headlessly.
116+
/// On Linux/macOS it falls back to the OBS property-list probe, which is safe there.
112117
/// </summary>
113118
/// <param name="includeVirtualDevices">
114119
/// If true, also return virtual / proxy entries that have no DirectShow device path —
@@ -117,8 +122,17 @@ public WebcamCapture(string name, string? deviceId = null)
117122
/// </param>
118123
public static IReadOnlyList<WebcamDeviceInfo> ListDevices(bool includeVirtualDevices = false)
119124
{
120-
// The dshow plugin only populates the device list when an instance exists, so we
121-
// create a private (un-saved) source just for the property query and dispose it.
125+
if (OperatingSystem.IsWindows())
126+
return ListDevicesWindows(includeVirtualDevices);
127+
128+
return ListDevicesViaObsProbe(includeVirtualDevices);
129+
}
130+
131+
// Fallback path used on Linux/macOS. On Windows this code is unsafe in the presence of
132+
// buggy third-party DShow filters because the dshow plugin CoCreates each filter when
133+
// populating its property list.
134+
private static IReadOnlyList<WebcamDeviceInfo> ListDevicesViaObsProbe(bool includeVirtualDevices)
135+
{
122136
using var probe = Source.CreatePrivate(TypeIdForPlatform, "__obskit_webcam_probe__");
123137
var items = probe.GetListPropertyItems(VideoDeviceIdKey);
124138

@@ -128,11 +142,6 @@ public static IReadOnlyList<WebcamDeviceInfo> ListDevices(bool includeVirtualDev
128142
if (string.IsNullOrEmpty(itemValue))
129143
continue;
130144

131-
// OBS encodes device ids as "Name:Path" (the dshow plugin escapes any literal
132-
// ':' and '#' inside the components, so the first ':' is always the separator).
133-
// Entries with an empty path component are virtual / proxy devices (Meta Quest
134-
// companion app, NVIDIA Broadcast, OBS Virtual Camera, ...). Skip them unless
135-
// the caller opts in.
136145
if (!includeVirtualDevices && OperatingSystem.IsWindows())
137146
{
138147
var sep = itemValue.IndexOf(':');
@@ -145,6 +154,141 @@ public static IReadOnlyList<WebcamDeviceInfo> ListDevices(bool includeVirtualDev
145154
return result;
146155
}
147156

157+
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
158+
private static IReadOnlyList<WebcamDeviceInfo> ListDevicesWindows(bool includeVirtualDevices)
159+
{
160+
var result = new List<WebcamDeviceInfo>();
161+
object? devEnumObj = null;
162+
IEnumMoniker? enumMoniker = null;
163+
164+
try
165+
{
166+
var sysDeviceEnumType = Type.GetTypeFromCLSID(CLSID_SystemDeviceEnum, throwOnError: false);
167+
if (sysDeviceEnumType == null)
168+
return result;
169+
170+
devEnumObj = Activator.CreateInstance(sysDeviceEnumType);
171+
if (devEnumObj is not ICreateDevEnum devEnum)
172+
return result;
173+
174+
int hr = devEnum.CreateClassEnumerator(CLSID_VideoInputDeviceCategory, out enumMoniker, 0);
175+
// S_FALSE (1) means the category is empty.
176+
if (hr != 0 || enumMoniker == null)
177+
return result;
178+
179+
var monikers = new IMoniker[1];
180+
while (enumMoniker.Next(1, monikers, IntPtr.Zero) == 0)
181+
{
182+
var moniker = monikers[0];
183+
if (moniker == null)
184+
continue;
185+
186+
try
187+
{
188+
string? name = ReadStringProperty(moniker, "FriendlyName");
189+
if (string.IsNullOrEmpty(name))
190+
name = ReadStringProperty(moniker, "Description");
191+
if (string.IsNullOrEmpty(name))
192+
continue;
193+
194+
string? path = ReadStringProperty(moniker, "DevicePath") ?? string.Empty;
195+
196+
if (!includeVirtualDevices && string.IsNullOrEmpty(path))
197+
continue;
198+
199+
string deviceId = EncodeDeviceId(name!, path);
200+
result.Add(new WebcamDeviceInfo(name!, deviceId));
201+
}
202+
finally
203+
{
204+
Marshal.ReleaseComObject(moniker);
205+
}
206+
}
207+
}
208+
catch
209+
{
210+
// Swallow — enumeration must never throw past this boundary. Callers can deal
211+
// with an empty list (e.g. show a "no devices found" UI) but a thrown exception
212+
// would propagate up through the OBS thread and look like a crash.
213+
}
214+
finally
215+
{
216+
if (enumMoniker != null)
217+
Marshal.ReleaseComObject(enumMoniker);
218+
if (devEnumObj != null)
219+
Marshal.ReleaseComObject(devEnumObj);
220+
}
221+
222+
return result;
223+
}
224+
225+
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
226+
private static string? ReadStringProperty(IMoniker moniker, string propertyName)
227+
{
228+
object? bagObj = null;
229+
try
230+
{
231+
var bagGuid = IID_IPropertyBag;
232+
moniker.BindToStorage(null!, null!, ref bagGuid, out bagObj);
233+
if (bagObj is not IPropertyBag bag)
234+
return null;
235+
236+
object? value = null;
237+
int hr = bag.Read(propertyName, ref value, IntPtr.Zero);
238+
if (hr != 0 || value == null)
239+
return null;
240+
return value.ToString();
241+
}
242+
catch
243+
{
244+
return null;
245+
}
246+
finally
247+
{
248+
if (bagObj != null)
249+
Marshal.ReleaseComObject(bagObj);
250+
}
251+
}
252+
253+
// Mirror of obs-studio's win-dshow/encode-dstr.hpp::encode_dstr. The OBS dshow plugin
254+
// produces device ids of the form "{name}:{path}" with '#' replaced by "#22" and ':'
255+
// by "#3A" inside each component, so we must produce the exact same string for the
256+
// dshow plugin to recognise the device when we later assign it via SetDevice().
257+
private static string EncodeDeviceId(string name, string path)
258+
{
259+
string encName = EncodeDshowComponent(name);
260+
string encPath = EncodeDshowComponent(path);
261+
return $"{encName}:{encPath}";
262+
}
263+
264+
private static string EncodeDshowComponent(string s)
265+
{
266+
if (string.IsNullOrEmpty(s))
267+
return s;
268+
return s.Replace("#", "#22").Replace(":", "#3A");
269+
}
270+
271+
private static readonly Guid CLSID_SystemDeviceEnum = new("62BE5D10-60EB-11d0-BD3B-00A0C911CE86");
272+
private static readonly Guid CLSID_VideoInputDeviceCategory = new("860BB310-5D01-11d0-BD3B-00A0C911CE86");
273+
private static readonly Guid IID_IPropertyBag = new("55272A00-42CB-11CE-8135-00AA004BB851");
274+
275+
[ComImport, Guid("29840822-5B84-11D0-BD3B-00A0C911CE86"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
276+
private interface ICreateDevEnum
277+
{
278+
[PreserveSig]
279+
int CreateClassEnumerator([In] in Guid clsidDeviceClass, out IEnumMoniker? ppEnumMoniker, [In] int dwFlags);
280+
}
281+
282+
[ComImport, Guid("55272A00-42CB-11CE-8135-00AA004BB851"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
283+
private interface IPropertyBag
284+
{
285+
[PreserveSig]
286+
int Read([MarshalAs(UnmanagedType.LPWStr)] string pszPropName, [In, Out, MarshalAs(UnmanagedType.Struct)] ref object? pVar, IntPtr pErrorLog);
287+
288+
[PreserveSig]
289+
int Write([MarshalAs(UnmanagedType.LPWStr)] string pszPropName, [In, MarshalAs(UnmanagedType.Struct)] ref object pVar);
290+
}
291+
148292
private static Settings BuildInitialSettings(string? deviceId)
149293
{
150294
var settings = new Settings();

0 commit comments

Comments
 (0)