using System.Threading; using System.Threading.Tasks; using Lib.io.video.swiftcam; using SharpDX; using SharpDX.DXGI; using Device = SharpDX.Direct3D11.Device; using StatusLevel = T3.Core.Operator.Interfaces.IStatusProvider.StatusLevel; namespace Lib.io.video; /// /// Captures live video from a Swift Imaging scientific USB camera via the vendor SDK. /// Delivers BGRA8 frames into a Texture2D using the SDK's pull-mode callback. /// All SDK lifecycle calls (Open, Stop, Close, StartPullModeWithCallback) run on a background /// thread so the editor's render thread is never blocked by camera I/O. /// Requires swiftcam.dll from the Swift Imaging SDK at %LOCALAPPDATA%\TiXL\NativeDeps\swiftcam.dll /// (or pointed at via the TIXL_SWIFTCAM_DLL environment variable). /// [Guid("5ea3d627-e44b-4531-9f06-4503a1a4dd0e")] public class SwiftCamDevice : Instance, ICustomDropdownHolder, IStatusProvider { [Output(Guid = "5f1552ab-e9e5-49ac-b2f1-3019becf6f96", DirtyFlagTrigger = DirtyFlagTrigger.Animated)] public readonly Slot Texture = new(); [Output(Guid = "c5d201f4-020c-4f84-9ba2-1a3e59423f22", DirtyFlagTrigger = DirtyFlagTrigger.Animated)] public readonly Slot Resolution = new(); [Output(Guid = "d6862579-03df-48e8-a495-070fb6614497", DirtyFlagTrigger = DirtyFlagTrigger.Animated)] public new readonly Slot Status = new(); [Output(Guid = "083525a9-1c9d-4ac4-96dc-915a4c0b6aaa", DirtyFlagTrigger = DirtyFlagTrigger.Animated)] public readonly Slot UpdateCount = new(); [Input(Guid = "d73af73f-e71c-4a6b-a4cf-f8069c1db30d")] public readonly InputSlot Active = new(); [Input(Guid = "c44f7b21-0418-490f-a2c1-4d57b70bfa32")] public readonly InputSlot DeviceName = new(); [Input(Guid = "df2f5f6b-2b27-44b7-989d-6c8225113234")] public readonly InputSlot ResolutionIndex = new(); [Input(Guid = "e9bc114b-75ac-4bec-a337-5b16404da4de")] public readonly InputSlot AutoExposure = new(); [Input(Guid = "48e138e2-c31f-45fe-81f2-cf3789fe7d10")] public readonly InputSlot Exposure = new(); [Input(Guid = "370fc7fa-29f8-492e-b443-cce968ece722")] public readonly InputSlot AnalogGainFactor = new(); [Input(Guid = "ea6b3503-9a68-405e-9b34-7f44a74d5b31")] public readonly InputSlot RoiResolution = new(); [Input(Guid = "3e021070-038f-453d-96ca-33c27bc8ddc4")] public readonly InputSlot RoiAlignment = new(); [Input(Guid = "7965cea6-2fe4-4240-9f9c-ab2af0aedea8")] public readonly InputSlot LogMessages = new(); [Input(Guid = "7cd0acca-d843-472e-9fcc-e8deaf129336")] public readonly InputSlot Reconnect = new(); [Input(Guid = "caa5d946-060d-45eb-907b-601c4f0a677c")] public readonly InputSlot HardReset = new(); static SwiftCamDevice() { // Pre-load swiftcam.dll into the process by full path. Later [DllImport("swiftcam.dll")] calls // resolve to the already-loaded module via the per-process module table, sidestepping TiXL's // load-context resolver (which only probes the assembly's directory + the application directory). TryPreloadSwiftcamDll(); } public SwiftCamDevice() { Texture.UpdateAction = Update; Resolution.UpdateAction = Update; Status.UpdateAction = Update; UpdateCount.UpdateAction = Update; } public StatusLevel GetStatusLevel() => _statusLevel; public string GetStatusMessage() => _statusMessage; private static void TryPreloadSwiftcamDll() { try { var envPath = Environment.GetEnvironmentVariable("TIXL_SWIFTCAM_DLL"); if (!string.IsNullOrEmpty(envPath) && File.Exists(envPath) && NativeLibrary.TryLoad(envPath, out _)) { Log.Debug($"SwiftCamDevice: pre-loaded swiftcam.dll from TIXL_SWIFTCAM_DLL='{envPath}'"); return; } var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var userPath = Path.Combine(localAppData, "TiXL", "NativeDeps", "swiftcam.dll"); if (File.Exists(userPath)) { if (NativeLibrary.TryLoad(userPath, out _)) Log.Debug($"SwiftCamDevice: pre-loaded swiftcam.dll from '{userPath}'"); else Log.Warning($"SwiftCamDevice: file at '{userPath}' exists but NativeLibrary.TryLoad failed (likely bitness mismatch)"); } else { Log.Debug($"SwiftCamDevice: '{userPath}' not found - SDK probe will fail unless DLL is on default search path"); } } catch (Exception e) { Log.Warning($"SwiftCamDevice: pre-load threw - {e.Message}"); } } string ICustomDropdownHolder.GetValueForInput(Guid inputId) { if (inputId == DeviceName.Id) return DeviceName.Value; if (inputId == ResolutionIndex.Id) { var idx = ResolutionIndex.Value; if (TryFindDevice(DeviceName.Value, out var device) && device.model.res is { } res && idx >= 0 && idx < res.Length) { var w = res[idx].width; var h = res[idx].height; return idx == 0 ? $"{idx}: {w}x{h} (full)" : $"{idx}: {w}x{h}"; } return idx.ToString(); } return string.Empty; } IEnumerable ICustomDropdownHolder.GetOptionsForInput(Guid inputId) { if (inputId == DeviceName.Id) return EnumerateDeviceOptions(); if (inputId == ResolutionIndex.Id) return EnumerateResolutionOptions(); return Array.Empty(); } private IEnumerable EnumerateDeviceOptions() { if (_sdkAvailable == null) _sdkAvailable = TryProbeSdk(); if (_sdkAvailable == false) { yield return SdkMissingMessage; yield break; } ScanDevices(); if (_devices == null || _devices.Length == 0) { yield return "No Swift cameras found"; yield break; } for (var i = 0; i < _devices.Length; i++) yield return _devices[i].displayname; } private IEnumerable EnumerateResolutionOptions() { if (_sdkAvailable != true) { yield return "(SDK not available)"; yield break; } ScanDevices(); if (!TryFindDevice(DeviceName.Value, out var device)) { yield return "(Pick a device first)"; yield break; } var count = (int)device.model.preview; var resArray = device.model.res; if (resArray == null || count <= 0) { yield return "(No resolutions reported)"; yield break; } for (var i = 0; i < count && i < resArray.Length; i++) { var w = resArray[i].width; var h = resArray[i].height; var label = i == 0 ? $"{i}: {w}x{h} (full)" : $"{i}: {w}x{h}"; yield return label; } } void ICustomDropdownHolder.HandleResultForInput(Guid inputId, string selected, bool isAListItem) { if (inputId == DeviceName.Id) { DeviceName.SetTypedInputValue(selected); return; } if (inputId == ResolutionIndex.Id && !string.IsNullOrEmpty(selected)) { // Labels are formatted as "{index}: WxH ...". Parse the leading integer. var colon = selected.IndexOf(':'); var head = colon > 0 ? selected[..colon] : selected; if (int.TryParse(head, out var index)) ResolutionIndex.SetTypedInputValue(index); } } private void Update(EvaluationContext context) { // Cache verbose flag for the SDK callback thread. _logVerbose = LogMessages.GetValue(context); if (_sdkAvailable == null) _sdkAvailable = TryProbeSdk(); if (Reconnect.GetValue(context)) { Reconnect.SetTypedInputValue(false); Log.Debug("SwiftCamDevice: Reconnect triggered", this); BeginStop(); _devices = null; _sdkAvailable = null; _backoffUntilTickMs = 0; _consecutiveSilentFailures = 0; SetStatus("Reconnecting…", StatusLevel.Notice); Status.Value = _statusMessage; return; } if (HardReset.GetValue(context)) { HardReset.SetTypedInputValue(false); Log.Warning("SwiftCamDevice: HardReset triggered — sensor reset + flush + reopen", this); // Try a sensor-side reset and buffer flush first. These are fire-and-forget; the // camera may or may not actually be in a state to receive them, but it's worth // trying before the more disruptive Close/Open cycle. if (_camera != null) { try { _camera.put_Option(Swiftcam.eOPTION.OPTION_RESET_SENSOR, 1); } catch (Exception e) { Log.Debug($"SwiftCamDevice: OPTION_RESET_SENSOR failed - {e.Message}", this); } try { _camera.put_Option(Swiftcam.eOPTION.OPTION_FLUSH, 3); } catch (Exception e) { Log.Debug($"SwiftCamDevice: OPTION_FLUSH failed - {e.Message}", this); } } BeginStop(); _devices = null; _sdkAvailable = null; _backoffUntilTickMs = 0; _consecutiveSilentFailures = 0; SetStatus("Hard reset…", StatusLevel.Notice); Status.Value = _statusMessage; return; } if (_sdkAvailable == false) { SetStatus(SdkMissingMessage, StatusLevel.Error); Status.Value = _statusMessage; return; } PollPendingTasks(); HandleErrorFlags(); ScanDevices(); var deviceName = DeviceName.GetValue(context); if (string.IsNullOrEmpty(deviceName) && _devices != null && _devices.Length > 0) deviceName = _devices[0].displayname; var deviceFound = TryFindDeviceId(deviceName, out var deviceId); var active = Active.GetValue(context); if (!deviceFound) { if (_state == CamState.Streaming || _state == CamState.Starting) BeginStop(); if (_state == CamState.Idle) SetStatus($"Camera '{deviceName}' not found.", StatusLevel.Warning); } else if (active) { var snap = SnapshotStartConfig(context, deviceId); if (_state == CamState.Streaming && snap.Config.RequiresRestart(_openedConfig)) { Log.Debug("SwiftCamDevice: config changed during streaming - restarting", this); BeginStop(); _backoffUntilTickMs = 0; // user-driven change — restart promptly _consecutiveSilentFailures = 0; } else if (_state == CamState.Idle && snap.Config.RequiresRestart(_openedConfig)) { // User changed config while we were idle (1 s backoff or suspended after repeated // failures). Clear the suspension so we retry promptly with the new config. _backoffUntilTickMs = 0; _consecutiveSilentFailures = 0; } if (_state == CamState.Idle && Environment.TickCount64 >= _backoffUntilTickMs) { BeginStart(snap); } else if (_state == CamState.Idle) { // In backoff window — keep last status (set when error fired). } if (_state == CamState.Streaming) { ApplyAutoExposureAndExposure(context); ApplyGain(context); } } else // not active { if (_state == CamState.Streaming || _state == CamState.Starting) BeginStop(); if (_state == CamState.Idle) SetStatus("Inactive.", StatusLevel.Notice); } UploadLatestFrame(); Status.Value = _statusMessage; } private static bool TryProbeSdk() { try { var devices = Swiftcam.EnumV2(); Log.Debug($"SwiftCamDevice: SDK probe succeeded, EnumV2 returned {devices?.Length ?? 0} device(s)"); return true; } catch (DllNotFoundException e) { Log.Warning($"SwiftCamDevice: SDK probe failed - DllNotFoundException: {e.Message}"); return false; } catch (BadImageFormatException e) { Log.Warning($"SwiftCamDevice: SDK probe failed - BadImageFormatException: {e.Message}"); return false; } } private void ScanDevices() { if (_devices != null) return; try { _devices = Swiftcam.EnumV2() ?? Array.Empty(); if (_logVerbose) { Log.Debug($"SwiftCamDevice: enumerated {_devices.Length} device(s)", this); for (var i = 0; i < _devices.Length; i++) Log.Debug($"SwiftCamDevice: [{i}] '{_devices[i].displayname}' id='{_devices[i].id}'", this); } } catch (Exception e) { Log.Warning($"SwiftCamDevice: enumeration failed - {e.Message}", this); _devices = Array.Empty(); } } private bool TryFindDeviceId(string displayName, out string id) { if (TryFindDevice(displayName, out var device)) { id = device.id; return true; } id = null; return false; } private bool TryFindDevice(string displayName, out Swiftcam.DeviceV2 device) { device = default; if (_devices == null || string.IsNullOrEmpty(displayName)) return false; for (var i = 0; i < _devices.Length; i++) { if (_devices[i].displayname == displayName) { device = _devices[i]; return true; } } return false; } private StartSnapshot SnapshotStartConfig(EvaluationContext context, string deviceId) { return new StartSnapshot { Config = new StreamConfig { DeviceId = deviceId, ResolutionIndex = (uint)Math.Max(0, ResolutionIndex.GetValue(context)), RoiResolution = RoiResolution.GetValue(context), RoiAlignment = RoiAlignment.GetValue(context), }, InitialAutoExposure = AutoExposure.GetValue(context), InitialExposureMs = Exposure.GetValue(context), InitialGainFactor = AnalogGainFactor.GetValue(context), }; } private void BeginStart(StartSnapshot snap) { Log.Debug($"SwiftCamDevice: BeginStart resIndex={snap.Config.ResolutionIndex} " + $"autoExpo={snap.InitialAutoExposure} exposureMs={snap.InitialExposureMs:F1} " + $"gain={snap.InitialGainFactor:F2}x (ROI applied mid-stream after warm-up)", this); SetStatus("Starting…", StatusLevel.Notice); _state = CamState.Starting; _openedConfig = snap.Config; _loggedFirstFrame = false; _framesReceivedSinceStart = 0; var verbose = _logVerbose; // DoStart runs on a background thread so the render thread isn't blocked by Swiftcam.Open // (~1.3 s) or StartPullModeWithCallback (also slow on this SDK). _startTask = Task.Run(() => DoStart(snap, verbose)); } private StartResult DoStart(StartSnapshot snap, bool verbose) { Swiftcam cam = null; try { cam = Swiftcam.Open(snap.Config.DeviceId); if (cam == null) return new StartResult { ErrorMessage = "Failed to open Swift camera." }; // Real-time delivery — vendor sample calls this right after Open. var rtOk = cam.put_RealTime(1); if (verbose) Log.Debug($"SwiftCamDevice: put_RealTime(1) = {rtOk}", this); // Note: deliberately NOT calling put_Option(OPTION_RGB, RGB32). The vendor's samples // never set this, and combining native RGB32 with non-zero ROI offsets seems to // trigger EVENT_ERROR shortly after streaming starts on this SDK family. We accept // a per-frame RGB24→RGB32 conversion inside the SDK (PullImage with bits=32) in // exchange for ROI working reliably. var sizeOk = cam.put_eSize(snap.Config.ResolutionIndex); if (verbose) Log.Debug($"SwiftCamDevice: put_eSize({snap.Config.ResolutionIndex}) = {sizeOk}", this); // Reset any persisted ROI before measuring sensor dimensions. ROI bounds in this SDK // are relative to the *current* effective size, not the original sensor — if a prior // ROI carried over, get_Size returns the cropped dimensions and our offset math // computes against the wrong base. var resetOk = cam.put_Roi(0, 0, 0, 0); if (verbose) Log.Debug($"SwiftCamDevice: put_Roi(0,0,0,0) reset = {resetOk}", this); if (!cam.get_Size(out var sensorW, out var sensorH) || sensorW <= 0 || sensorH <= 0) { cam.Close(); return new StartResult { ErrorMessage = "Camera reported an invalid resolution." }; } if (verbose) Log.Debug($"SwiftCamDevice: get_Size = {sensorW}x{sensorH}", this); // Apply ROI before starting streaming. (Mid-stream ROI changes on the SC1003 cause // EVENT_ERROR — neither pre-start nor post-start works reliably, but pre-start is the // pattern the vendor's WinForms sample follows for resolution changes.) ComputeRoi(snap.Config.RoiResolution, snap.Config.RoiAlignment, sensorW, sensorH, out var roiX, out var roiY, out var roiW, out var roiH); var roiOk = cam.put_Roi(roiX, roiY, roiW, roiH); if (verbose) Log.Debug($"SwiftCamDevice: put_Roi({roiX},{roiY},{roiW},{roiH}) = {roiOk}", this); var aeOk = cam.put_AutoExpoEnable(snap.InitialAutoExposure); if (verbose) Log.Debug($"SwiftCamDevice: put_AutoExpoEnable({snap.InitialAutoExposure}) = {aeOk}", this); int appliedExposureUs = -1; if (!snap.InitialAutoExposure && snap.InitialExposureMs > 0) { var us = (uint)Math.Max(1, (int)Math.Round(snap.InitialExposureMs * 1000f)); cam.put_ExpoTime(us); appliedExposureUs = (int)us; if (verbose) Log.Debug($"SwiftCamDevice: put_ExpoTime({us})", this); } // AnalogGainFactor: 1.0 = unity (= 100% in the SDK). Camera's EXPOGAIN_MIN is 100. var gainPercent = (int)Math.Round(snap.InitialGainFactor * 100f); int appliedGainPercent = -1; if (gainPercent >= Swiftcam.EXPOGAIN_MIN) { cam.put_ExpoAGain((ushort)gainPercent); appliedGainPercent = gainPercent; if (verbose) Log.Debug($"SwiftCamDevice: put_ExpoAGain({gainPercent}%)", this); } cam.get_Size(out var effW, out var effH); if (effW <= 0) effW = sensorW; if (effH <= 0) effH = sensorH; // Start streaming on this background thread. StartPullModeWithCallback can take // 1–3 seconds on this SDK family — keeping it off the render thread is critical. var cb = new Swiftcam.DelegateEventCallback(OnEventCallback); if (!cam.StartPullModeWithCallback(cb)) { cam.Close(); return new StartResult { ErrorMessage = "Swift camera failed to start streaming." }; } return new StartResult { Camera = cam, EventCallback = cb, SensorWidth = sensorW, SensorHeight = sensorH, EffectiveWidth = effW, EffectiveHeight = effH, AppliedAutoExposure = snap.InitialAutoExposure, AppliedExposureUs = appliedExposureUs, AppliedGainPercent = appliedGainPercent, AppliedRoi = (roiX, roiY, roiW, roiH), }; } catch (Exception e) { try { cam?.Close(); } catch { /* best effort */ } return new StartResult { ErrorMessage = $"Start failed: {e.Message}" }; } } private void PollPendingTasks() { if (_state == CamState.Starting && _startTask != null && _startTask.IsCompleted) { var r = _startTask.Result; _startTask = null; if (r.ErrorMessage != null) { Log.Warning($"SwiftCamDevice: {r.ErrorMessage}", this); SetStatus(r.ErrorMessage, StatusLevel.Error); _state = CamState.Idle; ScheduleStartRetry(); } else { _camera = r.Camera; _eventCallback = r.EventCallback; _sensorWidth = r.SensorWidth; _sensorHeight = r.SensorHeight; _appliedAutoExposure = r.AppliedAutoExposure; _appliedExposureUs = r.AppliedExposureUs; _appliedGainPercent = r.AppliedGainPercent; _appliedRoi = r.AppliedRoi; EnsurePullBuffer(r.EffectiveWidth, r.EffectiveHeight); _state = CamState.Streaming; SetStatus("Streaming.", StatusLevel.Success); Log.Debug("SwiftCamDevice: streaming started", this); if (_stopRequestedDuringStart) { _stopRequestedDuringStart = false; BeginStop(); } } } if (_state == CamState.Stopping && _stopTask != null && _stopTask.IsCompleted) { _stopTask = null; _state = CamState.Idle; Log.Debug("SwiftCamDevice: stopped", this); // Force a settling period before the next Open. Stop/Open in rapid succession is // what wedges the SC1003 camera firmware most reliably. var settleUntil = Environment.TickCount64 + PostStopCooldownMs; if (settleUntil > _backoffUntilTickMs) _backoffUntilTickMs = settleUntil; } } private void HandleErrorFlags() { if (_disconnected) { _disconnected = false; Log.Warning("SwiftCamDevice: disconnect detected, auto-retrying", this); SetStatus("Camera disconnected. Auto-retrying…", StatusLevel.Warning); BeginStop(); ScheduleStartRetry(); return; } if (_hasError) { _hasError = false; // If the start succeeded but no frame ever arrived before EVENT_ERROR fired, the // current config (most often a non-zero RoiResolution) is unsupported by this // camera/SDK. Bail after a few attempts rather than retry-looping forever. if (_framesReceivedSinceStart == 0) { _consecutiveSilentFailures++; Log.Warning($"SwiftCamDevice: SDK error before first frame " + $"(attempt {_consecutiveSilentFailures}/{MaxSilentFailures})", this); if (_consecutiveSilentFailures >= MaxSilentFailures) { SetStatus("Camera configuration not supported (likely a non-zero RoiResolution " + "on a camera whose SDK rejects mid-stream ROI). Set RoiResolution " + "to (0, 0) and try Reconnect.", StatusLevel.Error); BeginStop(); _backoffUntilTickMs = long.MaxValue; // suspend retries until config changes return; } } else { Log.Warning("SwiftCamDevice: SDK error after streaming, auto-retrying", this); } SetStatus("Camera reported an error. Auto-retrying…", StatusLevel.Error); BeginStop(); ScheduleStartRetry(); } } private void BeginStop() { switch (_state) { case CamState.Idle: case CamState.Stopping: return; case CamState.Starting: // Open/Start is in-flight on a background thread; can't cancel cleanly. // Defer the stop to when the start task completes. _stopRequestedDuringStart = true; return; case CamState.Streaming: DoBeginStop(); return; } } private void DoBeginStop() { var cam = _camera; _camera = null; _eventCallback = null; _hasNewFrame = false; _state = CamState.Stopping; Log.Debug("SwiftCamDevice: BeginStop", this); _stopTask = Task.Run(() => SafeStopAndClose(cam)); } private static void SafeStopAndClose(Swiftcam cam) { if (cam == null) return; try { cam.Stop(); cam.Close(); } catch (Exception e) { Log.Warning($"SwiftCamDevice: Stop/Close failed - {e.Message}"); } } private void ScheduleStartRetry() { // Force re-enumeration on next attempt — picks up "camera unplugged" rather than // blindly re-trying the cached id. _devices = null; _backoffUntilTickMs = Environment.TickCount64 + StartBackoffMs; } private void EnsurePullBuffer(int width, int height) { var stride = width * BytesPerPixel; var totalBytes = stride * height; lock (_frameLock) { if (_frameBuffer == IntPtr.Zero || _frameBufferCapacity < totalBytes) { if (_frameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(_frameBuffer); _frameBuffer = Marshal.AllocHGlobal(totalBytes); _frameBufferCapacity = totalBytes; } _expectedWidth = width; _expectedHeight = height; _expectedStride = stride; } } /// /// Maps user-facing (RoiResolution, RoiAlignment) into hardware (x, y, w, h) snapped to /// 2-pixel boundaries (Bayer-pattern alignment). RoiResolution X or Y <= 0, or a crop /// equal/greater than the sensor in both axes, disables ROI (returns 0,0,0,0). /// /// The camera ultimately decides which sizes are valid. The vendor's own app surfaces /// "Out of range" errors for many seemingly-reasonable sizes (e.g. 1920×1080 isn't /// accepted on the SC1003 — try 1954×1062 instead). When put_Roi succeeds at the SDK /// level but the camera silently rejects the size, the operator's silent-failure /// detection kicks in and surfaces a clear error to the user. /// private static void ComputeRoi(Int2 roiRes, Vector2 alignment, int sensorW, int sensorH, out uint x, out uint y, out uint w, out uint h) { if (roiRes.X <= 0 || roiRes.Y <= 0 || (roiRes.X >= sensorW && roiRes.Y >= sensorH)) { x = 0; y = 0; w = 0; h = 0; return; } var snapW = SnapDownTo2(Math.Min(sensorW, roiRes.X)); var snapH = SnapDownTo2(Math.Min(sensorH, roiRes.Y)); var slackX = (sensorW - snapW) / 2; var slackY = (sensorH - snapH) / 2; var ax = Math.Clamp(alignment.X, -1f, 1f); var ay = Math.Clamp(alignment.Y, -1f, 1f); var offsetX = slackX + (int)Math.Round(ax * slackX); var offsetY = slackY + (int)Math.Round(ay * slackY); offsetX = SnapDownTo2(Math.Clamp(offsetX, 0, sensorW - snapW)); offsetY = SnapDownTo2(Math.Clamp(offsetY, 0, sensorH - snapH)); x = (uint)offsetX; y = (uint)offsetY; w = (uint)snapW; h = (uint)snapH; } private static int SnapDownTo2(int v) => Math.Max(0, (v / 2) * 2); private void ApplyAutoExposureAndExposure(EvaluationContext context) { if (_camera == null) return; var auto = AutoExposure.GetValue(context); if (_appliedAutoExposure != auto) { try { _camera.put_AutoExpoEnable(auto); _appliedAutoExposure = auto; LogVerbose($"put_AutoExpoEnable({auto})"); } catch (Exception e) { Log.Warning($"SwiftCamDevice: put_AutoExpoEnable failed - {e.Message}", this); } } if (auto) return; // SDK manages exposure; ignore the slider while AE is on. var ms = Exposure.GetValue(context); if (!(ms > 0f)) return; var us = (int)Math.Round(ms * 1000f); if (us == _appliedExposureUs) return; try { _camera.put_ExpoTime((uint)Math.Max(1, us)); _appliedExposureUs = us; } catch (Exception e) { Log.Warning($"SwiftCamDevice: put_ExpoTime failed - {e.Message}", this); } } private void ApplyGain(EvaluationContext context) { if (_camera == null) return; // AnalogGainFactor: 1.0 = unity, 5.0 = 5x. SDK takes percent (100 = unity). var gainPercent = (int)Math.Round(AnalogGainFactor.GetValue(context) * 100f); if (gainPercent < Swiftcam.EXPOGAIN_MIN || gainPercent == _appliedGainPercent) return; try { _camera.put_ExpoAGain((ushort)gainPercent); _appliedGainPercent = gainPercent; } catch (Exception e) { Log.Warning($"SwiftCamDevice: put_ExpoAGain failed - {e.Message}", this); } } private void OnEventCallback(Swiftcam.eEVENT nEvent) { // Runs on SDK thread. switch (nEvent) { case Swiftcam.eEVENT.EVENT_IMAGE: if (_logVerbose) Log.Debug($"SwiftCamDevice: event {nEvent}", this); PullFrame(); break; case Swiftcam.eEVENT.EVENT_DISCONNECTED: Log.Warning("SwiftCamDevice: event EVENT_DISCONNECTED", this); _disconnected = true; break; case Swiftcam.eEVENT.EVENT_ERROR: Log.Warning("SwiftCamDevice: event EVENT_ERROR", this); _hasError = true; break; case Swiftcam.eEVENT.EVENT_NOFRAMETIMEOUT: Log.Warning("SwiftCamDevice: event EVENT_NOFRAMETIMEOUT (no frame received within timeout)", this); _hasError = true; break; case Swiftcam.eEVENT.EVENT_NOPACKETTIMEOUT: Log.Warning("SwiftCamDevice: event EVENT_NOPACKETTIMEOUT (no USB packet within timeout)", this); _hasError = true; break; default: if (_logVerbose) Log.Debug($"SwiftCamDevice: event {nEvent}", this); break; } } private void PullFrame() { var cam = _camera; if (cam == null) return; IntPtr buffer; int width, height, stride, capacity; lock (_frameLock) { buffer = _frameBuffer; width = _expectedWidth; height = _expectedHeight; stride = _expectedStride; capacity = _frameBufferCapacity; } if (buffer == IntPtr.Zero || width <= 0 || height <= 0 || capacity < stride * height) { LogVerbose($"PullFrame skipped - buffer not ready (capacity={capacity}, want={stride * height})"); return; } try { if (!cam.PullImage(buffer, 0, BitsPerPixel, stride, out var info)) { LogVerbose("PullImage returned false"); return; } if (!_loggedFirstFrame) { Log.Debug($"SwiftCamDevice: first frame received - {info.v3.width}x{info.v3.height}, " + $"seq={info.v3.seq}, flag=0x{info.v3.flag:X}, expotime={info.v3.expotime}us, " + $"expogain={info.v3.expogain}", this); _loggedFirstFrame = true; _consecutiveSilentFailures = 0; // a successful frame breaks the give-up loop } _framesReceivedSinceStart++; lock (_frameLock) { _frameWidth = (int)info.v3.width; _frameHeight = (int)info.v3.height; _frameStride = stride; _frameSeq = info.v3.seq; _hasNewFrame = true; } } catch (Exception e) { Log.Warning($"SwiftCamDevice: PullImage threw - {e.Message}", this); } } private void UploadLatestFrame() { IntPtr buffer; int width, height, stride; lock (_frameLock) { if (!_hasNewFrame) return; buffer = _frameBuffer; width = _frameWidth; height = _frameHeight; stride = _frameStride; _hasNewFrame = false; } EnsureGpuTexture(width, height, Format.B8G8R8A8_UNorm); var dataBox = new DataBox(buffer, stride, 0); ResourceManager.Device.ImmediateContext.UpdateSubresource(dataBox, _gpuTexture); Texture.Value = _gpuTexture; Resolution.Value = new Int2(width, height); UpdateCount.Value++; } private void EnsureGpuTexture(int width, int height, Format format) { if (_gpuTexture != null && _gpuWidth == width && _gpuHeight == height && _gpuFormat == format) return; Log.Debug($"SwiftCamDevice: allocating GPU texture {width}x{height} {format}", this); _gpuTexture?.Dispose(); var desc = new Texture2DDescription { Width = width, Height = height, MipLevels = 1, ArraySize = 1, Format = format, SampleDescription = new SampleDescription(1, 0), Usage = ResourceUsage.Default, BindFlags = BindFlags.ShaderResource, CpuAccessFlags = CpuAccessFlags.None, OptionFlags = ResourceOptionFlags.None, }; _gpuTexture = new Texture2D(new SharpDX.Direct3D11.Texture2D(ResourceManager.Device, desc)); _gpuWidth = width; _gpuHeight = height; _gpuFormat = format; } private void SetStatus(string message, StatusLevel level) { _statusMessage = message; _statusLevel = level; } private void LogVerbose(string msg) { if (_logVerbose) Log.Debug($"SwiftCamDevice: {msg}", this); } protected override void Dispose(bool disposing) { if (!disposing) return; BeginStop(); // Wait for any pending lifecycle task so we don't leak the camera handle. var pending = new List(); if (_startTask != null) pending.Add(_startTask); if (_stopTask != null) pending.Add(_stopTask); if (pending.Count > 0) { try { Task.WaitAll(pending.ToArray(), TimeSpan.FromSeconds(3)); } catch { // Best effort; if a task fails or times out, fall through and free what we own. } } // If the start task succeeded but we didn't pick up its result, the camera handle is in // the result. Drain it. if (_startTask != null && _startTask.IsCompletedSuccessfully) { try { _startTask.Result?.Camera?.Close(); } catch { /* best effort */ } } lock (_frameLock) { if (_frameBuffer != IntPtr.Zero) { Marshal.FreeHGlobal(_frameBuffer); _frameBuffer = IntPtr.Zero; _frameBufferCapacity = 0; } } _gpuTexture?.Dispose(); _gpuTexture = null; } private const string SdkMissingMessage = "swiftcam.dll not found. Copy the x64 DLL from the Swift Imaging SDK to " + "%LOCALAPPDATA%\\TiXL\\NativeDeps\\swiftcam.dll, then trigger Reconnect."; private const int BitsPerPixel = 32; private const int BytesPerPixel = 4; // Cooldown after a failed start or runtime error so we don't hammer Swiftcam.Open. // Short enough that a transient USB hiccup auto-recovers within a second or two. private const long StartBackoffMs = 1000; // After this many "started but no frame before EVENT_ERROR" cycles, suspend retries until // the user changes config or triggers Reconnect. Indicates the current config is unsupported // (typically a non-zero RoiResolution on a camera whose SDK rejects mid-stream ROI changes). private const int MaxSilentFailures = 3; // After a Stop/Close completes, wait this long before the next Open. The Swift firmware seems // to need a settling period — quick Stop+Open cycles tend to wedge the camera into a state // only physical USB unplug can recover. This is conservative; faster cameras may not need it. private const long PostStopCooldownMs = 3000; private enum CamState { Idle, Starting, Streaming, Stopping, } private struct StreamConfig { public string DeviceId; public uint ResolutionIndex; public Int2 RoiResolution; public Vector2 RoiAlignment; // Any change here triggers a clean Stop + cooldown + Open cycle. Mid-stream ROI changes // proved unreliable on the SC1003 (EVENT_ERROR ~1s after EVENT_ROI), so we treat ROI like // any other restart-required setting. public bool RequiresRestart(StreamConfig other) { return DeviceId != other.DeviceId || ResolutionIndex != other.ResolutionIndex || RoiResolution != other.RoiResolution || RoiAlignment != other.RoiAlignment; } } private struct StartSnapshot { public StreamConfig Config; public bool InitialAutoExposure; public float InitialExposureMs; public float InitialGainFactor; } private class StartResult { public Swiftcam Camera; public Swiftcam.DelegateEventCallback EventCallback; public int SensorWidth; public int SensorHeight; public int EffectiveWidth; public int EffectiveHeight; public bool AppliedAutoExposure; public int AppliedExposureUs; public int AppliedGainPercent; public (uint X, uint Y, uint W, uint H) AppliedRoi; public string ErrorMessage; } // State machine + lifecycle tasks private CamState _state = CamState.Idle; private Task _startTask; private Task _stopTask; private bool _stopRequestedDuringStart; private long _backoffUntilTickMs; // SDK + device cache private bool? _sdkAvailable; private Swiftcam.DeviceV2[] _devices; private volatile bool _logVerbose; // Streaming-state fields (only valid while _state == CamState.Streaming) private Swiftcam _camera; private Swiftcam.DelegateEventCallback _eventCallback; private StreamConfig _openedConfig; private int _sensorWidth; private int _sensorHeight; private (uint X, uint Y, uint W, uint H) _appliedRoi; private bool? _appliedAutoExposure; private int _appliedExposureUs = -1; private int _appliedGainPercent = -1; private volatile bool _disconnected; private volatile bool _hasError; private volatile bool _loggedFirstFrame; private volatile int _framesReceivedSinceStart; private int _consecutiveSilentFailures; // Frame buffer (filled by PullFrame on SDK thread, read by UploadLatestFrame on render thread) private readonly Lock _frameLock = new(); private IntPtr _frameBuffer; private int _frameBufferCapacity; private int _expectedWidth; private int _expectedHeight; private int _expectedStride; private int _frameWidth; private int _frameHeight; private int _frameStride; private uint _frameSeq; private bool _hasNewFrame; // GPU texture (recreated when dimensions change) private Texture2D _gpuTexture; private int _gpuWidth; private int _gpuHeight; private Format _gpuFormat; // Status (read by IStatusProvider) private string _statusMessage = ""; private StatusLevel _statusLevel = StatusLevel.Undefined; }