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

263 lines
9.8 KiB
C#

#nullable enable
using System.Threading;
using SharpDX;
using T3.Core.Video;
using T3.VideoServices;
using Utilities = T3.Core.Utils.Utilities;
namespace Lib.io.video
{
[Guid("D9A7233D-5D03-4268-A58B-465972852A5B")]
internal sealed class VideoStreamInput : Instance<VideoStreamInput>, IStatusProvider
{
private readonly Lock _lockObject = new();
[Input(Guid = "2E26E552-68D7-4E2F-8208-831F2A75C96B")]
public readonly InputSlot<bool> Connect = new();
[Input(Guid = "9A240243-71B5-4235-86A9-D5369A3311A9")]
public readonly InputSlot<bool> Reconnect = new();
[Output(Guid = "2E7E2404-5881-4327-9653-CA9533B856A9", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
public readonly Slot<Texture2D?> Texture = new();
[Output(Guid = "3F6A960C-906A-4073-A338-ABB785869062", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
public readonly Slot<Int2> Resolution = new();
[Output(Guid = "B0E4313B-A746-4444-934E-14285D42DADB", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
public new readonly Slot<string> Status = new();
[Input(Guid = "A8B0971B-94CB-4C3D-932D-337581B8D83A")]
public readonly InputSlot<string> Url = new("rtsp://your_stream_url");
private CancellationTokenSource? _cancellationTokenSource;
private Thread? _captureThread;
private Texture2D? _gpuTexture;
private bool _lastConnectState;
private volatile string _lastStatusMessage = "Not connected.";
private string _lastUrl = string.Empty;
// The most recent decoded frame as packed RGBA, shared between the capture thread and the render thread.
private byte[]? _sharedRgba;
private int _sharedWidth;
private int _sharedHeight;
private bool _hasFrame;
public VideoStreamInput()
{
Texture.UpdateAction = Update;
}
public IStatusProvider.StatusLevel GetStatusLevel()
{
return _lastStatusMessage.StartsWith("Error") ? IStatusProvider.StatusLevel.Error : IStatusProvider.StatusLevel.Success;
}
public string GetStatusMessage()
{
return _lastStatusMessage;
}
private void SetStatus(string message) => _lastStatusMessage = message;
private void Update(EvaluationContext context)
{
var url = Url.GetValue(context) ?? string.Empty;
var shouldConnect = Connect.GetValue(context);
var reconnect = Reconnect.GetValue(context);
if (reconnect) Reconnect.SetTypedInputValue(false);
var settingsChanged = shouldConnect != _lastConnectState || (shouldConnect && url != _lastUrl);
if (shouldConnect)
{
if (IsCaptureThreadRunning() && (settingsChanged || reconnect)) StopCaptureThread();
if (!IsCaptureThreadRunning())
{
_lastUrl = url;
StartCaptureThread(url);
}
}
else
{
StopCaptureThread();
}
_lastConnectState = shouldConnect;
lock (_lockObject)
{
if (_hasFrame && _sharedRgba != null)
{
UploadFrameToGpu(_sharedRgba, _sharedWidth, _sharedHeight);
Texture.Value = _gpuTexture;
Resolution.Value = new Int2(_sharedWidth, _sharedHeight);
}
else
{
Texture.Value = null;
}
}
// Update the status as a side effect. This does NOT trigger a new update.
Status.Value = _lastStatusMessage;
}
private bool IsCaptureThreadRunning() => _captureThread is { IsAlive: true };
private void StartCaptureThread(string url)
{
if (IsCaptureThreadRunning() || string.IsNullOrWhiteSpace(url)) return;
_cancellationTokenSource = new CancellationTokenSource();
var token = _cancellationTokenSource.Token;
_captureThread = new Thread(() => CaptureLoop(url, token))
{
IsBackground = true,
Name = "VideoStream Capture Thread"
};
_captureThread.Start();
}
private void StopCaptureThread()
{
if (!IsCaptureThreadRunning()) return;
_cancellationTokenSource?.Cancel();
_captureThread?.Join(TimeSpan.FromSeconds(3));
_captureThread = null;
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
SetStatus("Disconnected");
}
private void CaptureLoop(string url, CancellationToken token)
{
VideoDecoderSession? session = null;
SoftwareFrameConverter? converter = null;
try
{
SetStatus($"Connecting to {url}...");
// A live stream is consumed sequentially through this op's own SoftwareFrameConverter, so it needs
// CPU-side frames — FastSeeking decodes in software, which provides exactly that.
session = VideoDecoderSession.TryOpen(url, VideoPlaybackOptimization.FastSeeking, out var error, BuildDemuxerOptions(url));
if (token.IsCancellationRequested)
return;
if (session == null)
{
SetStatus($"Error: Failed to open stream '{url}'. {error}");
return;
}
converter = new SoftwareFrameConverter(session.IsHdr);
SetStatus("Streaming");
while (!token.IsCancellationRequested)
{
if (!session.TryReadNextFrame(out _))
{
SetStatus("Warning: Stream ended or interrupted.");
break;
}
var rgba = converter.Convert(session.CurrentFrame);
var byteCount = rgba.Width * rgba.Height * converter.BytesPerPixel;
lock (_lockObject)
{
if (_sharedRgba == null || _sharedRgba.Length < byteCount)
_sharedRgba = new byte[byteCount];
rgba.FillImageBuffer(_sharedRgba, 1);
_sharedWidth = rgba.Width;
_sharedHeight = rgba.Height;
_hasFrame = true;
}
}
}
catch (Exception e)
{
if (!token.IsCancellationRequested)
{
Log.Error($"Video stream thread failed: {e.Message}", this);
SetStatus($"Error: {e.Message}");
}
}
finally
{
converter?.Dispose();
session?.Dispose();
lock (_lockObject)
{
_hasFrame = false;
}
}
}
// TCP is more reliable than the default UDP, and a socket timeout keeps a dead URL from hanging the open.
private static IReadOnlyDictionary<string, string>? BuildDemuxerOptions(string url)
{
if (!url.StartsWith("rtsp", StringComparison.OrdinalIgnoreCase))
return null;
return new Dictionary<string, string>
{
["rtsp_transport"] = "tcp",
["timeout"] = "5000000", // microseconds
};
}
private unsafe void UploadFrameToGpu(byte[] buffer, int width, int height)
{
if (width <= 0 || height <= 0) return;
if (_gpuTexture == null || _gpuTexture.Description.Width != width || _gpuTexture.Description.Height != height)
{
Utilities.Dispose(ref _gpuTexture);
_gpuTexture = Texture2D.CreateTexture2D(new Texture2DDescription
{
Width = width,
Height = height,
MipLevels = 1,
ArraySize = 1,
Format = Format.R8G8B8A8_UNorm,
SampleDescription = new SampleDescription(1, 0),
Usage = ResourceUsage.Default,
BindFlags = BindFlags.ShaderResource,
CpuAccessFlags = CpuAccessFlags.None,
OptionFlags = ResourceOptionFlags.None
});
}
fixed (byte* pixels = buffer)
{
var dataBox = new DataBox((IntPtr)pixels, width * 4, 0);
ResourceManager.Device.ImmediateContext.UpdateSubresource(dataBox, _gpuTexture, 0);
}
}
protected override void Dispose(bool isDisposing)
{
if (IsDisposed)
return;
if (!isDisposing)
return;
StopCaptureThread();
Utilities.Dispose(ref _gpuTexture);
lock (_lockObject)
{
_sharedRgba = null;
_hasFrame = false;
}
}
}
}