Skip to content

Commit c97cc41

Browse files
committed
feat: add webcam support
Closes #1
1 parent ebbef66 commit c97cc41

9 files changed

Lines changed: 572 additions & 4 deletions

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<TargetFramework>net9.0</TargetFramework>
3+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
44
<LangVersion>13.0</LangVersion>
55
<Nullable>enable</Nullable>
66
<ImplicitUsings>enable</ImplicitUsings>

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ A modern .NET 9 wrapper for OBS Studio, providing a fluent C# API for video reco
99
- **Streaming** - Stream to Twitch, YouTube, Facebook, or custom RTMP servers
1010
- **Recording** - Record video to MP4, MKV, FLV, and more
1111
- **Replay Buffer** - Keep a rolling buffer of the last N seconds
12-
- **Sources** - Monitor capture, window capture, game capture, images, media files
12+
- **Sources** - Monitor capture, window capture, game capture, webcam, images, media files
1313
- **Encoders** - x264, NVENC (H.264/HEVC), AAC audio
1414
- **Headless Operation** - Run without GUI dependencies
1515

@@ -122,6 +122,13 @@ using var game = new GameCapture("Game", GameCapture.CaptureMode.AnyFullscreen);
122122
// Image and media
123123
using var image = ImageSource.FromFile("logo.png");
124124
using var media = new MediaSource("Video", "video.mp4").SetLooping(true);
125+
126+
// Webcam / video capture device (DirectShow on Windows, V4L2 on Linux, AVFoundation on macOS)
127+
foreach (var d in WebcamCapture.ListDevices())
128+
Console.WriteLine($" {d.Name} -> {d.DeviceId}");
129+
using var webcam = WebcamCapture.FromDeviceName("BRIO") // partial name match
130+
?? WebcamCapture.FromDefault(); // first device
131+
webcam?.SetCustomResolution(3840, 2160, 30, videoFormat: "MJPEG"); // optional: force 4K30
125132
```
126133

127134
## Screenshots

global.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"sdk": {
3-
"version": "9.0.0",
4-
"rollForward": "latestMinor"
3+
"version": "10.0.0",
4+
"rollForward": "latestMajor"
55
}
66
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<RootNamespace>ObsKit.NET.Sample.Webcam</RootNamespace>
6+
<ApplicationManifest>app.manifest</ApplicationManifest>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<ProjectReference Include="..\..\src\ObsKit.NET\ObsKit.NET.csproj" />
11+
</ItemGroup>
12+
13+
</Project>
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
using ObsKit.NET;
2+
using ObsKit.NET.Outputs;
3+
using ObsKit.NET.Sources;
4+
5+
Console.WriteLine("ObsKit.NET - Webcam Recording Test");
6+
Console.WriteLine("===================================\n");
7+
8+
var obsPath = AppContext.BaseDirectory;
9+
10+
if (!File.Exists(Path.Combine(obsPath, "obs.dll")))
11+
{
12+
Console.WriteLine("ERROR: OBS runtime not found at " + obsPath);
13+
Console.WriteLine("Expected obs.dll alongside the executable. See README.md.");
14+
return 1;
15+
}
16+
17+
using var obs = Obs.Initialize(config => config
18+
.WithDataPath(Path.Combine(obsPath, "data", "libobs"))
19+
.WithModulePath(
20+
Path.Combine(obsPath, "obs-plugins", "64bit"),
21+
Path.Combine(obsPath, "data", "obs-plugins", "%module%"))
22+
.ForHeadlessOperation()
23+
.WithVideo(v => v.Resolution(1920, 1080).Fps(30))
24+
.WithAudio(a => a.WithSampleRate(48000)));
25+
26+
Console.WriteLine($"OBS {Obs.Version} initialized\n");
27+
28+
// Enumerate webcams
29+
var devices = WebcamCapture.ListDevices();
30+
Console.WriteLine($"Available video capture devices ({devices.Count}):");
31+
for (int i = 0; i < devices.Count; i++)
32+
Console.WriteLine($" [{i}] {devices[i].Name}");
33+
34+
if (devices.Count == 0)
35+
{
36+
Console.WriteLine("\nNo video capture devices found. Connect a webcam and try again.");
37+
return 1;
38+
}
39+
40+
// Pick the Logitech 4K (BRIO) by name when present; otherwise fall back to the first device.
41+
var chosen = devices.FirstOrDefault(d =>
42+
d.Name.Contains("BRIO", StringComparison.OrdinalIgnoreCase) ||
43+
d.Name.Contains("Logitech", StringComparison.OrdinalIgnoreCase) ||
44+
d.Name.Contains("4K", StringComparison.OrdinalIgnoreCase))
45+
?? devices[0];
46+
47+
Console.WriteLine($"\nSelected: {chosen.Name}");
48+
49+
using var webcam = new WebcamCapture("Webcam", chosen.DeviceId);
50+
51+
// Give the device a moment to start producing frames; some webcams take a beat after open.
52+
Console.WriteLine("Warming up capture device...");
53+
for (int i = 0; i < 30 && webcam.Width == 0; i++)
54+
Thread.Sleep(100);
55+
56+
Console.WriteLine($"Capture stream: {webcam.Width}x{webcam.Height}");
57+
if (webcam.Width == 0 || webcam.Height == 0)
58+
{
59+
Console.WriteLine("WARN: webcam reports 0x0 — frames may not be flowing yet. Will continue anyway.");
60+
}
61+
62+
// Match the canvas to the device's native resolution so we record what the camera produces.
63+
var canvasW = webcam.Width > 0 ? webcam.Width : 1920u;
64+
var canvasH = webcam.Height > 0 ? webcam.Height : 1080u;
65+
Obs.SetVideo(v => v.Resolution(canvasW, canvasH).Fps(30));
66+
67+
// Build a scene with the webcam.
68+
using var scene = Obs.Scenes.Create("Webcam Scene");
69+
scene.AddSource(webcam);
70+
scene.SetAsProgram();
71+
72+
Console.WriteLine($"Scene has {scene.ItemCount} source(s); canvas {canvasW}x{canvasH}\n");
73+
74+
// Sample a frame from the GPU before recording — confirms non-black content is reaching the canvas.
75+
Thread.Sleep(500);
76+
var probe = webcam.TakeScreenshot();
77+
if (probe != null)
78+
{
79+
long sum = 0;
80+
int sampleStride = Math.Max(1, probe.Pixels.Length / 4096);
81+
for (int i = 0; i < probe.Pixels.Length; i += sampleStride)
82+
sum += probe.Pixels[i];
83+
double avg = (double)sum / (probe.Pixels.Length / sampleStride);
84+
Console.WriteLine($"Pre-record screenshot: {probe.Width}x{probe.Height}, mean byte value = {avg:F1}");
85+
if (avg < 1.0)
86+
Console.WriteLine("WARN: screenshot looks black. The recording may still capture light if the device starts producing frames during record.");
87+
}
88+
else
89+
{
90+
Console.WriteLine("Pre-record screenshot returned null.");
91+
}
92+
93+
var outputPath = Path.Combine(Environment.CurrentDirectory, $"webcam_{DateTime.Now:yyyyMMdd_HHmmss}.mp4");
94+
Console.WriteLine($"\nRecording to: {outputPath}");
95+
96+
using var recording = new RecordingOutput("Webcam Recording")
97+
.SetPath(outputPath)
98+
.SetFormat(RecordingFormat.Mp4)
99+
.WithDefaultEncoders(videoBitrate: 6000, audioBitrate: 192);
100+
101+
if (!recording.Start())
102+
{
103+
Console.WriteLine($"Failed to start: {recording.LastError}");
104+
return 1;
105+
}
106+
107+
// Record a fixed 5 seconds so the test is deterministic (no user input needed).
108+
const int recordSeconds = 5;
109+
Console.WriteLine($"Recording for {recordSeconds} seconds...");
110+
for (int i = 0; i < recordSeconds; i++)
111+
{
112+
Thread.Sleep(1000);
113+
Console.WriteLine($" ...{i + 1}s frames={recording.TotalFrames} bytes={recording.TotalBytes:N0}");
114+
}
115+
116+
var totalFrames = recording.TotalFrames;
117+
var totalBytes = recording.TotalBytes;
118+
recording.Stop(); // waits for completion, then auto-disposes when Obs.AutoDispose is true
119+
120+
Console.WriteLine($"\nStopped: frames={totalFrames} bytes={totalBytes:N0}");
121+
122+
// Validate the output file exists and has plausible size.
123+
if (!File.Exists(outputPath))
124+
{
125+
Console.WriteLine("FAIL: no output file produced.");
126+
return 1;
127+
}
128+
129+
var fi = new FileInfo(outputPath);
130+
Console.WriteLine($"File: {outputPath} ({fi.Length:N0} bytes)");
131+
132+
if (totalFrames == 0)
133+
{
134+
Console.WriteLine("FAIL: zero frames written to recording.");
135+
return 1;
136+
}
137+
138+
Console.WriteLine("\nSUCCESS: webcam recording wrote frames. Open the MP4 to confirm content.");
139+
return 0;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
3+
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
4+
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
5+
<security>
6+
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
7+
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
8+
</requestedPrivileges>
9+
</security>
10+
</trustInfo>
11+
12+
<application xmlns="urn:schemas-microsoft-com:asm.v3">
13+
<windowsSettings>
14+
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
15+
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
16+
</windowsSettings>
17+
</application>
18+
19+
</assembly>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System.Runtime.CompilerServices;
2+
using System.Runtime.InteropServices;
3+
using System.Runtime.InteropServices.Marshalling;
4+
using ObsKit.NET.Native.Marshalling;
5+
using ObsKit.NET.Native.Types;
6+
7+
namespace ObsKit.NET.Native.Interop;
8+
9+
/// <summary>
10+
/// P/Invoke bindings for OBS property enumeration. Properties expose the dynamic
11+
/// option lists (device pickers, resolutions, etc.) that source plugins populate.
12+
/// </summary>
13+
internal static partial class ObsProperties
14+
{
15+
private const string Lib = LibraryLoader.ObsLibraryName;
16+
17+
/// <summary>Gets the properties for a source instance.</summary>
18+
[LibraryImport(Lib, EntryPoint = "obs_source_properties")]
19+
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
20+
internal static partial nint obs_source_properties(ObsSourceHandle source);
21+
22+
/// <summary>Gets the default properties for a source type id.</summary>
23+
[LibraryImport(Lib, EntryPoint = "obs_get_source_properties")]
24+
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
25+
internal static partial nint obs_get_source_properties(
26+
[MarshalUsing(typeof(Utf8StringMarshaler))] string id);
27+
28+
/// <summary>Destroys a properties object returned by obs_source_properties.</summary>
29+
[LibraryImport(Lib, EntryPoint = "obs_properties_destroy")]
30+
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
31+
internal static partial void obs_properties_destroy(nint props);
32+
33+
/// <summary>Gets a property by name from a properties object.</summary>
34+
[LibraryImport(Lib, EntryPoint = "obs_properties_get")]
35+
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
36+
internal static partial nint obs_properties_get(
37+
nint props,
38+
[MarshalUsing(typeof(Utf8StringMarshaler))] string property);
39+
40+
/// <summary>Gets the number of items in a list property.</summary>
41+
[LibraryImport(Lib, EntryPoint = "obs_property_list_item_count")]
42+
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
43+
internal static partial nuint obs_property_list_item_count(nint property);
44+
45+
/// <summary>Gets the display name of a list item.</summary>
46+
[LibraryImport(Lib, EntryPoint = "obs_property_list_item_name")]
47+
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
48+
[return: MarshalUsing(typeof(Utf8StringMarshalerNoFree))]
49+
internal static partial string? obs_property_list_item_name(nint property, nuint idx);
50+
51+
/// <summary>Gets the string value of a list item.</summary>
52+
[LibraryImport(Lib, EntryPoint = "obs_property_list_item_string")]
53+
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
54+
[return: MarshalUsing(typeof(Utf8StringMarshalerNoFree))]
55+
internal static partial string? obs_property_list_item_string(nint property, nuint idx);
56+
57+
/// <summary>Gets whether a list item is disabled.</summary>
58+
public static bool obs_property_list_item_disabled(nint property, nuint idx)
59+
=> obs_property_list_item_disabled_native(property, idx) != 0;
60+
61+
[LibraryImport(Lib, EntryPoint = "obs_property_list_item_disabled")]
62+
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
63+
private static partial byte obs_property_list_item_disabled_native(nint property, nuint idx);
64+
65+
/// <summary>Gets the integer value of a list item (for int-typed list properties).</summary>
66+
[LibraryImport(Lib, EntryPoint = "obs_property_list_item_int")]
67+
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
68+
internal static partial long obs_property_list_item_int(nint property, nuint idx);
69+
}

src/ObsKit.NET/Sources/Source.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,44 @@ public SignalConnection ConnectSignal(string signal, SignalCallback callback)
248248

249249
#endregion
250250

251+
#region Properties (option lists)
252+
253+
/// <summary>
254+
/// Enumerates the items of a list-type property exposed by this source's plugin.
255+
/// Returns (display name, value) pairs. Used to discover devices, resolutions, etc.
256+
/// </summary>
257+
/// <param name="propertyName">The property key (e.g. "video_device_id").</param>
258+
public IReadOnlyList<(string Name, string Value)> GetListPropertyItems(string propertyName)
259+
{
260+
var result = new List<(string, string)>();
261+
var props = ObsProperties.obs_source_properties(Handle);
262+
if (props == 0)
263+
return result;
264+
265+
try
266+
{
267+
var prop = ObsProperties.obs_properties_get(props, propertyName);
268+
if (prop == 0)
269+
return result;
270+
271+
var count = ObsProperties.obs_property_list_item_count(prop);
272+
for (nuint i = 0; i < count; i++)
273+
{
274+
var name = ObsProperties.obs_property_list_item_name(prop, i) ?? string.Empty;
275+
var value = ObsProperties.obs_property_list_item_string(prop, i) ?? string.Empty;
276+
result.Add((name, value));
277+
}
278+
}
279+
finally
280+
{
281+
ObsProperties.obs_properties_destroy(props);
282+
}
283+
284+
return result;
285+
}
286+
287+
#endregion
288+
251289
#region Screenshot
252290

253291
// OBS graphics constants

0 commit comments

Comments
 (0)