Files
2026-07-13 13:13:17 +08:00

746 lines
27 KiB
C#

using System.Runtime.InteropServices.ComTypes;
using System.Threading;
using DirectShowLib;
using OpenCvSharp;
using SharpDX;
using Device = SharpDX.Direct3D11.Device;
using FormatType = DirectShowLib.FormatType;
namespace Lib.io.video;
[Guid("cd5a182e-254b-4e65-820b-ff754122614c")]
public class VideoDeviceInput : Instance<VideoDeviceInput>, ICustomDropdownHolder
{
// Output slots
[Output(Guid = "1d0159cc-33d2-46b1-9c0c-7054aa560df5", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
public readonly Slot<Texture2D> Texture = new();
[Output(Guid = "9C2E4C11-09B6-4F4D-8F99-4A7372D5F2B5", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
public readonly Slot<Int2> Resolution = new();
[Output(Guid = "A1B2C3D4-5678-90EF-1234-567890ABCDEF", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
public new readonly Slot<string> Status = new();
[Output(Guid = "868D5FFE-032C-4522-B56B-D96B30841DB7", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
public readonly Slot<int> UpdateCount = new();
private const float Epsilon = 1e-4f;
// Static device information
private static List<WebcamWithIndex> _webcamWithIndices;
private static Dictionary<string, List<(int Width, int Height, double Fps)>> _webcamCapabilities;
private static readonly object _capabilitiesLock = new();
private static readonly Lock _staticInitLock = new();
private static bool _devicesScanned;
private readonly Lock _captureDeviceLock = new();
private readonly Lock _lockObject = new();
[Input(Guid = "236D4C5C-0022-4416-A22C-D6DF73C306E2")]
public readonly InputSlot<bool> Active = new();
[Input(Guid = "3022DE8A-5D88-4A37-9799-780F2A838A6E")]
public readonly InputSlot<float> ApplyRotationData = new();
[Input(Guid = "11F7432D-31F1-44B9-8F75-B1569B314B13")]
public readonly InputSlot<int> CustomFps = new();
[Input(Guid = "C9E1C1F6-3A18-4A1C-8A5E-4B4119965B6E")]
public readonly InputSlot<Int2> CustomResolution = new();
[Input(Guid = "22513B82-E77A-417A-8A46-24E677F072D4")]
public readonly InputSlot<bool> DeactivateWhenNotShowing = new();
[Input(Guid = "A57E815D-70C9-4D3B-998C-D13506B8F56E")]
public readonly InputSlot<bool> FlipHorizontally = new();
[Input(Guid = "3022DE8A-5D88-4A37-9799-780F2A838A5F")]
public readonly InputSlot<bool> FlipVertically = new();
[Input(Guid = "f5b900ec-ee17-123e-9972-cdd0580c104e")]
public readonly InputSlot<string> InputDeviceName = new();
[Input(Guid = "8D2C28C7-1234-40E2-9388-75574519543D")]
public readonly InputSlot<bool> OpenSettings = new();
[Input(Guid = "67E149A5-F7B6-47BC-B147-3B9B11C19C29")]
public readonly InputSlot<bool> Reconnect = new();
[Input(Guid = "F187A997-7E4A-48C6-81F9-2A27F150A68A")]
public readonly InputSlot<Vector2> Reposition = new();
[Input(Guid = "49019D29-873E-4B7C-A897-C575A384A650", MappedType = typeof(ResolutionFpsTypeEnum))]
public readonly InputSlot<int> ResolutionFpsType = new();
[Input(Guid = "805602D5-52B2-4A73-A337-12E00C3C91F2")]
public readonly InputSlot<Vector2> Scale = new();
private CancellationTokenSource _cancellationTokenSource;
// Instance variables
private VideoCapture _capture;
private Thread _captureThread;
private Texture2D _gpuTexture;
private int _height;
private volatile string _lastStatusMessage = "";
private Mat _sharedMat;
private int _storeDeviceIndex = -1;
// Transformation cache
private Mat _transformationMatrix;
private int _width;
public VideoDeviceInput()
{
Texture.UpdateAction = Update;
UpdateCount.UpdateAction = Update;
Resolution.UpdateAction = Update;
Status.UpdateAction = Update;
}
private void SetStatus(string message)
{
_lastStatusMessage = message;
}
private void Update(EvaluationContext context)
{
ScanWebCamDevices();
var deviceName = InputDeviceName.GetValue(context);
if (string.IsNullOrEmpty(deviceName) && _webcamWithIndices?.Count > 0)
{
deviceName = _webcamWithIndices[0].Name;
}
if (!TryGetIndexForDeviceName(deviceName, out var selectedDeviceIndex))
{
SetStatus($"Error: Camera '{deviceName}' not found.");
StopCaptureThread();
}
else
{
var deviceIsActive = Active.GetValue(context);
var deactivateWhenNotShowing = DeactivateWhenNotShowing.GetValue(context);
bool shouldBeCapturing = deviceIsActive && (!deactivateWhenNotShowing || IsActiveInGraph());
bool reconnectTriggered = Reconnect.GetValue(context);
if (reconnectTriggered)
{
Reconnect.SetTypedInputValue(false);
}
if (shouldBeCapturing)
{
if (IsCaptureThreadRunning() && (selectedDeviceIndex != _storeDeviceIndex || reconnectTriggered))
{
StopCaptureThread();
}
if (!IsCaptureThreadRunning())
{
_storeDeviceIndex = selectedDeviceIndex;
StartCaptureThread(context);
}
}
else
{
StopCaptureThread();
}
}
if (OpenSettings.GetValue(context))
{
OpenVideoSettings();
OpenSettings.SetTypedInputValue(false);
}
lock (_lockObject)
{
if (_sharedMat != null && !_sharedMat.Empty())
{
UploadMat(ResourceManager.Device, _sharedMat);
Texture.Value = _gpuTexture;
UpdateCount.Value++;
Resolution.Value = new Int2(_sharedMat.Width, _sharedMat.Height);
}
}
Status.Value = _lastStatusMessage;
}
private bool IsCaptureThreadRunning()
{
return _captureThread != null && _captureThread.IsAlive;
}
private bool IsActiveInGraph() => true;
private void StartCaptureThread(EvaluationContext context)
{
if (IsCaptureThreadRunning()) return;
var settings = new CaptureThreadSettings(this, context);
_cancellationTokenSource = new CancellationTokenSource();
var token = _cancellationTokenSource.Token;
_captureThread = new Thread(() => CaptureLoop(settings, token))
{
IsBackground = true,
Name = "Video Capture Thread",
Priority = ThreadPriority.BelowNormal
};
_captureThread.Start();
}
private void StopCaptureThread()
{
if (!IsCaptureThreadRunning()) return;
_cancellationTokenSource?.Cancel();
if (!_captureThread.Join(TimeSpan.FromSeconds(2)))
{
Log.Debug("Video capture thread did not exit in time.");
}
_captureThread = null;
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
lock (_captureDeviceLock)
{
_capture?.Dispose();
_capture = null;
}
}
private void CaptureLoop(CaptureThreadSettings settings, CancellationToken token)
{
try
{
lock (_captureDeviceLock)
{
if (token.IsCancellationRequested) return;
_capture = new VideoCapture();
if (settings.ResolutionFpsType == ResolutionFpsTypeEnum.Custom)
{
_capture.Set(VideoCaptureProperties.FrameWidth, settings.CustomResolution.X);
_capture.Set(VideoCaptureProperties.FrameHeight, settings.CustomResolution.Y);
_capture.Set(VideoCaptureProperties.Fps, settings.CustomFps);
}
if (!_capture.Open(_storeDeviceIndex, VideoCaptureAPIs.DSHOW))
{
SetStatus("Error: Failed to open video capture device.");
return;
}
}
Thread.Sleep(100);
lock (_captureDeviceLock)
{
if (token.IsCancellationRequested || _capture == null) return;
if (settings.ResolutionFpsType == ResolutionFpsTypeEnum.Custom)
{
bool widthSet = _capture.Set(VideoCaptureProperties.FrameWidth, settings.CustomResolution.X);
bool heightSet = _capture.Set(VideoCaptureProperties.FrameHeight, settings.CustomResolution.Y);
bool fpsSet = _capture.Set(VideoCaptureProperties.Fps, settings.CustomFps);
if (!widthSet || !heightSet || !fpsSet)
{
Log.Debug("Set returned false—camera doesn't support requested settings.");
}
var actualWidth = (int)_capture.Get(VideoCaptureProperties.FrameWidth);
var actualHeight = (int)_capture.Get(VideoCaptureProperties.FrameHeight);
var actualFps = _capture.Get(VideoCaptureProperties.Fps);
if (actualWidth != settings.CustomResolution.X || actualHeight != settings.CustomResolution.Y ||
Math.Abs(actualFps - settings.CustomFps) > 0.01)
{
SetStatus($"Warning: Using {actualWidth}x{actualHeight}@{actualFps:F2}fps (requested {settings.CustomResolution.X}x{settings.CustomResolution.Y}@{settings.CustomFps}fps).");
}
else
{
SetStatus($"Success: Running at {settings.CustomResolution.X}x{settings.CustomResolution.Y}@{settings.CustomFps}fps.");
}
}
else
{
SetStatus("Running at device default settings.");
}
}
using var frame = new Mat();
using var bgraMat = new Mat();
while (!token.IsCancellationRequested)
{
Mat currentFrame = null;
bool frameReadSuccess = false;
lock (_captureDeviceLock)
{
if (_capture != null && _capture.IsOpened())
{
frameReadSuccess = _capture.Read(frame);
}
}
if (frameReadSuccess && !frame.Empty())
{
SetStatus("Running");
Cv2.CvtColor(frame, bgraMat, ColorConversionCodes.BGR2BGRA);
if (settings.FlipVertically)
Cv2.Flip(bgraMat, bgraMat, FlipMode.Y);
if (settings.FlipHorizontally)
Cv2.Flip(bgraMat, bgraMat, FlipMode.X);
if (settings.ResolutionFpsType == ResolutionFpsTypeEnum.Custom)
{
var targetSize = new Size(settings.CustomResolution.X, settings.CustomResolution.Y);
if (bgraMat.Size() != targetSize)
{
using var resized = new Mat();
Cv2.Resize(bgraMat, resized, targetSize, 0, 0, InterpolationFlags.Cubic);
currentFrame = resized.Clone();
SetStatus(_lastStatusMessage + " (Software resize)");
}
}
currentFrame ??= bgraMat;
if (settings.HasTransformation())
{
UpdateTransformationMatrix(settings, currentFrame.Size());
var newSize = new Size(currentFrame.Width, currentFrame.Height);
lock (_lockObject)
{
if (_sharedMat == null || _sharedMat.Width != newSize.Width || _sharedMat.Height != newSize.Height)
{
_sharedMat?.Dispose();
_sharedMat = new Mat(newSize, MatType.CV_8UC4);
}
Cv2.WarpAffine(currentFrame, _sharedMat, _transformationMatrix, newSize);
}
}
else
{
lock (_lockObject)
{
if (_sharedMat == null || _sharedMat.Width != currentFrame.Width || _sharedMat.Height != currentFrame.Height)
{
_sharedMat?.Dispose();
_sharedMat = new Mat(currentFrame.Height, currentFrame.Width, MatType.CV_8UC4);
}
currentFrame.CopyTo(_sharedMat);
}
}
if (currentFrame != bgraMat)
{
currentFrame.Dispose();
}
}
else
{
SetStatus("Error: Failed to retrieve frame.");
Thread.Sleep(100);
}
}
}
catch (Exception e)
{
// A TypeInitializationException (e.g. OpenCV's native NativeMethods cctor failing to load
// OpenCvSharpExtern.dll or one of its dependencies) carries the real cause — DllNotFound,
// BadImageFormat, etc. — only in its inner exception; the outer message is generic.
var cause = e.InnerException ?? e;
Log.Debug($"Video capture thread failed: {cause.GetType().Name}: {cause.Message}\n{e}");
SetStatus($"Error: Capture failed - {cause.Message}");
}
finally
{
lock (_captureDeviceLock)
{
_capture?.Dispose();
_capture = null;
}
_transformationMatrix?.Dispose();
}
}
private void UpdateTransformationMatrix(CaptureThreadSettings settings, Size frameSize)
{
_transformationMatrix?.Dispose();
_transformationMatrix = new Mat(2, 3, MatType.CV_32FC1);
var center = new Point2f(frameSize.Width / 2.0f, frameSize.Height / 2.0f);
var angleRad = settings.Rotation * Math.PI / 180.0;
var cos = (float)Math.Cos(angleRad);
var sin = (float)Math.Sin(angleRad);
_transformationMatrix.Set(0, 0, cos * settings.Scale.X);
_transformationMatrix.Set(0, 1, sin * settings.Scale.Y);
_transformationMatrix.Set(1, 0, -sin * settings.Scale.X);
_transformationMatrix.Set(1, 1, cos * settings.Scale.Y);
var tx = (1.0f - cos * settings.Scale.X) * center.X - sin * settings.Scale.Y * center.Y;
var ty = sin * settings.Scale.X * center.X + (1.0f - cos * settings.Scale.Y) * center.Y;
_transformationMatrix.Set(0, 2, settings.Reposition.X + tx);
_transformationMatrix.Set(1, 2, settings.Reposition.Y + ty);
}
private void UploadMat(Device device, Mat mat)
{
if (mat == null || mat.Empty())
{
Log.Debug("UploadMat: Mat is null or empty. Skipping upload.");
return;
}
var width = mat.Width;
var height = mat.Height;
if (_gpuTexture == null || _width != width || _height != height)
{
_gpuTexture?.Dispose();
_width = width;
_height = height;
var texDesc = new Texture2DDescription
{
Width = width,
Height = height,
MipLevels = 1,
ArraySize = 1,
Format = Format.B8G8R8A8_UNorm,
SampleDescription = new SampleDescription(1, 0),
Usage = ResourceUsage.Default,
BindFlags = BindFlags.ShaderResource,
CpuAccessFlags = CpuAccessFlags.None,
OptionFlags = ResourceOptionFlags.None
};
var sharpDxTexture = new SharpDX.Direct3D11.Texture2D(device, texDesc);
_gpuTexture = new Texture2D(sharpDxTexture);
}
var dataBox = new DataBox(mat.Data, (int)mat.Step(), 0);
device.ImmediateContext.UpdateSubresource(dataBox, _gpuTexture);
}
protected override void Dispose(bool disposing)
{
if (!disposing)
return;
StopCaptureThread();
_gpuTexture?.Dispose();
_gpuTexture = null;
_transformationMatrix?.Dispose();
_transformationMatrix = null;
lock (_lockObject)
{
_sharedMat?.Dispose();
_sharedMat = null;
}
}
private sealed record CaptureThreadSettings
{
public readonly int CustomFps;
public readonly Int2 CustomResolution;
public readonly bool FlipHorizontally;
public readonly bool FlipVertically;
public readonly Vector2 Reposition;
public readonly ResolutionFpsTypeEnum ResolutionFpsType;
public readonly float Rotation;
public readonly Vector2 Scale;
public CaptureThreadSettings(VideoDeviceInput owner, EvaluationContext context)
{
ResolutionFpsType = (ResolutionFpsTypeEnum)owner.ResolutionFpsType.GetValue(context);
CustomResolution = owner.CustomResolution.GetValue(context);
CustomFps = owner.CustomFps.GetValue(context);
FlipVertically = owner.FlipVertically.GetValue(context);
FlipHorizontally = owner.FlipHorizontally.GetValue(context);
Rotation = owner.ApplyRotationData.GetValue(context);
Reposition = owner.Reposition.GetValue(context);
Scale = owner.Scale.GetValue(context);
}
public bool HasTransformation()
{
return Math.Abs(Rotation) > Epsilon ||
Math.Abs(Scale.X - 1.0f) > Epsilon || Math.Abs(Scale.Y - 1.0f) > Epsilon ||
Math.Abs(Reposition.X) > Epsilon || Math.Abs(Reposition.Y) > Epsilon;
}
}
private enum ResolutionFpsTypeEnum
{
DeviceDefault = 0,
Custom = 1
}
#region Device Configuration UI
private void OpenVideoSettings()
{
try
{
lock (_captureDeviceLock)
{
if (_capture != null && _capture.IsOpened())
{
_capture.Set(VideoCaptureProperties.Settings, 1);
}
else
{
Log.Debug("Cannot open settings, capture device is not available.");
}
}
}
catch (Exception ex)
{
Log.Debug($"Failed to open video settings: {ex.Message}");
}
}
public void OpenCrossbarSettings()
{
Log.Debug("Crossbar settings not implemented for this version.");
}
#endregion
#region Dropdown UI
string ICustomDropdownHolder.GetValueForInput(Guid inputId)
{
if (inputId == InputDeviceName.Id) return InputDeviceName.Value;
if (inputId == CustomResolution.Id) return $"{CustomResolution.Value.X}x{CustomResolution.Value.Y}";
if (inputId == CustomFps.Id) return CustomFps.Value.ToString();
return string.Empty;
}
IEnumerable<string> ICustomDropdownHolder.GetOptionsForInput(Guid inputId)
{
if (inputId == InputDeviceName.Id)
{
if (_webcamWithIndices == null || _webcamWithIndices.Count == 0)
{
yield return "No devices found";
yield break;
}
foreach (var (webcam, _) in _webcamWithIndices)
yield return webcam;
}
else if (inputId == CustomResolution.Id)
{
var deviceName = InputDeviceName.Value;
lock (_capabilitiesLock)
{
if (_webcamCapabilities != null && _webcamCapabilities.ContainsKey(deviceName))
{
foreach (var (w, h, _) in _webcamCapabilities[deviceName].DistinctBy(c => (c.Width, c.Height)))
yield return $"{w}x{h}";
}
else
yield return "640x480";
}
}
else if (inputId == CustomFps.Id)
{
var deviceName = InputDeviceName.Value;
lock (_capabilitiesLock)
{
if (_webcamCapabilities != null && _webcamCapabilities.ContainsKey(deviceName))
{
foreach (var (_, _, fps) in _webcamCapabilities[deviceName].DistinctBy(c => c.Fps))
yield return $"{fps:F0}";
}
else
yield return "30";
}
}
}
void ICustomDropdownHolder.HandleResultForInput(Guid inputId, string selected, bool isAListItem)
{
if (inputId == InputDeviceName.Id)
InputDeviceName.SetTypedInputValue(selected);
else if (inputId == CustomResolution.Id)
{
if (selected != null)
{
var parts = selected.Split('x');
if (parts.Length == 2 &&
int.TryParse(parts[0], out var w) &&
int.TryParse(parts[1], out var h))
{
CustomResolution.SetTypedInputValue(new Int2(w, h));
var deviceName = InputDeviceName.Value;
lock (_capabilitiesLock)
{
if (_webcamCapabilities.TryGetValue(deviceName, out var caps) &&
!caps.Any(c => c.Width == w && c.Height == h))
{
SetStatus("Warning: Unsupported resolution—may fallback.");
}
}
}
}
}
else if (inputId == CustomFps.Id)
{
if (double.TryParse(selected, out var fps))
{
CustomFps.SetTypedInputValue((int)fps);
}
}
}
#endregion
#region Scanning for webcams and capabilities
public static void ScanWebCamDevices()
{
lock (_staticInitLock)
{
if (_devicesScanned)
return;
_webcamWithIndices = new List<WebcamWithIndex>();
lock (_capabilitiesLock)
{
_webcamCapabilities = new Dictionary<string, List<(int Width, int Height, double Fps)>>();
}
try
{
var videoInputDevices = DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice);
for (var i = 0; i < videoInputDevices.Length; i++)
{
var device = videoInputDevices[i];
_webcamWithIndices.Add(new WebcamWithIndex(device.Name, i));
var capabilities = new List<(int Width, int Height, double Fps)>();
try
{
if (device.Mon is not IMoniker moniker)
{
Log.Debug($"Moniker is null for device {device.Name}");
continue;
}
var filterGuid = typeof(IBaseFilter).GUID;
moniker.BindToObject(null, null, ref filterGuid, out var filter);
if (filter is not IBaseFilter captureFilter)
{
Log.Debug($"Failed to create capture filter for {device.Name}");
continue;
}
captureFilter.EnumPins(out var enumPins);
var pins = new IPin[1];
while (enumPins.Next(1, pins, IntPtr.Zero) == 0)
{
pins[0].QueryPinInfo(out var pinInfo);
if (pinInfo.dir == PinDirection.Output)
{
if (pins[0] is IAMStreamConfig streamConfig)
{
streamConfig.GetNumberOfCapabilities(out int count, out int size);
if (size > 0)
{
var capsPtr = Marshal.AllocCoTaskMem(size);
try
{
for (int j = 0; j < count; j++)
{
streamConfig.GetStreamCaps(j, out var mediaType, capsPtr);
if (mediaType != null &&
mediaType.formatType == FormatType.VideoInfo &&
mediaType.formatPtr != IntPtr.Zero)
{
var vih = Marshal.PtrToStructure<VideoInfoHeader>(mediaType.formatPtr);
int w = vih.BmiHeader.Width;
int h = vih.BmiHeader.Height;
double fps = vih.AvgTimePerFrame > 0
? 10_000_000.0 / vih.AvgTimePerFrame
: 30.0;
capabilities.Add((w, h, fps));
}
if (mediaType != null)
DsUtils.FreeAMMediaType(mediaType);
}
}
finally
{
Marshal.FreeCoTaskMem(capsPtr);
}
}
}
}
Marshal.ReleaseComObject(pins[0]);
}
Marshal.ReleaseComObject(enumPins);
Marshal.ReleaseComObject(captureFilter);
}
catch (Exception e)
{
Log.Debug($"Failed to get capabilities for {device.Name}: {e.Message}");
}
lock (_capabilitiesLock)
{
_webcamCapabilities.Add(device.Name, capabilities);
}
}
}
catch (Exception e)
{
Log.Debug("Failed to scan webcams: " + e.Message);
}
_devicesScanned = true;
}
}
private static bool TryGetIndexForDeviceName(string deviceName, out int index)
{
index = -1;
if (_webcamWithIndices == null) return false;
var device = _webcamWithIndices.FirstOrDefault(d => d.Name == deviceName);
if (device != null)
{
index = device.Index;
return true;
}
return false;
}
public sealed record WebcamWithIndex(string Name, int Index);
#endregion
}