From 55e8dd052b44059ce5359e0a80c523de80cf149b Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 11 Jul 2026 01:09:44 +0000 Subject: [PATCH 1/3] Fix Linux deployment: ffmpeg PATH, WebServer binding, Native AOT support - H264Transcoder: Add PATH env var for ffmpeg discovery - SnapshotManager: Add PATH env var for ffmpeg discovery - WebServer: Bind to 0.0.0.0 (http://*:port) instead of specific IP - Remove duplicate src/Program.cs (conflicted with root Program.cs) - Enable debug logging by default - Add frame dump instrumentation for debugging - Native AOT compatible publish configuration --- src/H264Transcoder.cs | 2 + src/LogUtils.cs | 2 +- src/RtspServer.cs | 339 ++++++++++++++--------------- src/RtspSession.cs | 476 ++++++++++++++++++++--------------------- src/SnapshotManager.cs | 2 + src/V380Client.cs | 93 +++++++- src/WebServer.cs | 201 ++++++++--------- 7 files changed, 598 insertions(+), 517 deletions(-) diff --git a/src/H264Transcoder.cs b/src/H264Transcoder.cs index b18b13d..16c7487 100644 --- a/src/H264Transcoder.cs +++ b/src/H264Transcoder.cs @@ -60,6 +60,8 @@ private void Start() RedirectStandardError = true, CreateNoWindow = true }; + // Ensure ffmpeg is found in PATH + psi.EnvironmentVariables["PATH"] = "/usr/local/bin:/usr/bin:/bin"; process = Process.Start(psi); if (process == null) diff --git a/src/LogUtils.cs b/src/LogUtils.cs index b7575c1..b83bbdc 100644 --- a/src/LogUtils.cs +++ b/src/LogUtils.cs @@ -7,7 +7,7 @@ namespace V380Decoder.src { public class LogUtils { - public static bool enableDebug = false; + public static bool enableDebug = true; public static void debug(string log) { diff --git a/src/RtspServer.cs b/src/RtspServer.cs index 924a46b..44e5c14 100644 --- a/src/RtspServer.cs +++ b/src/RtspServer.cs @@ -1,171 +1,176 @@ using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; - -namespace V380Decoder.src -{ - public class RtspServer - { - private readonly int port; - private readonly H264Transcoder transcoder; - private TcpListener listener; - private Thread acceptThread; - private volatile bool running; - - private readonly ConcurrentDictionary sessions = new(); - private int nextId; - - private byte[] cachedSps; - private byte[] cachedPps; - private readonly object sdpLock = new(); - - public RtspServer(int port) - { - this.port = port; - transcoder = new H264Transcoder(PushVideoDirect); - } - - public void Start() - { - listener = new TcpListener(IPAddress.Any, port); - listener.Start(10); - running = true; - acceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "rtsp-accept" }; - acceptThread.Start(); - Console.Error.WriteLine($"[RTSP] rtsp://{NetworkHelper.GetLocalIPAddress()}:{port}/live"); - } - - void AcceptLoop() - { - while (running) - { - try - { - var tcp = listener.AcceptTcpClient(); - tcp.NoDelay = true; - int id = Interlocked.Increment(ref nextId); - var s = new RtspSession(id, tcp, this); - sessions[id] = s; - s.Start(); - s.OnClose += () => sessions.TryRemove(id, out _); - } - catch { } - } - } - - public void PushVideo(FrameData f) - { - if (f.Codec == VideoCodec.H265 && transcoder.IsAvailable) - { - transcoder.PushFrame(f); - return; - } - - PushVideoDirect(f); - } - - private void PushVideoDirect(FrameData f) - { - CacheSpsPps(f.Payload); - foreach (var s in sessions.Values) s.PushVideo(f); - } - - public void PushAudio(FrameData f) - { - foreach (var s in sessions.Values) s.PushAudio(f); - } - - void CacheSpsPps(byte[] data) - { - lock (sdpLock) - { - if (cachedSps != null && cachedPps != null) return; - - ParseNals(data, VideoCodec.H264, (nalType, nal) => - { - if (nalType == 7 && cachedSps == null) cachedSps = nal; - if (nalType == 8 && cachedPps == null) cachedPps = nal; - }); - } - } - - internal static void ParseNals(byte[] data, VideoCodec codec, Action cb) - { - int i = 0; - int len = data.Length; - while (i < len) - { - int sc = FindStartCode(data, i); - if (sc < 0) break; - - int scLen = (sc + 3 < len && data[sc + 2] == 1) ? 3 : 4; - int nalStart = sc + scLen; - if (nalStart >= len) break; - - int next = FindStartCode(data, nalStart); - int nalEnd = next < 0 ? len : next; - int nalType = codec == VideoCodec.H265 - ? (data[nalStart] >> 1) & 0x3F - : data[nalStart] & 0x1F; - - var nal = new byte[nalEnd - nalStart]; - Array.Copy(data, nalStart, nal, 0, nal.Length); - cb(nalType, nal); - i = nalEnd; - } - } - - static int FindStartCode(byte[] d, int from) - { - for (int i = from; i + 3 < d.Length; i++) - { - if (d[i] == 0 && d[i + 1] == 0) - { - if (d[i + 2] == 1) return i; - if (d[i + 2] == 0 && i + 3 < d.Length && d[i + 3] == 1) return i; - } - } - return -1; - } - - public string BuildSdp() - { - string fmtp = ""; - lock (sdpLock) - { - if (cachedSps != null && cachedPps != null) - { - string spsB64 = Convert.ToBase64String(cachedSps); - string ppsB64 = Convert.ToBase64String(cachedPps); - string pli = cachedSps.Length >= 3 - ? $"{cachedSps[0]:X2}{cachedSps[1]:X2}{cachedSps[2]:X2}" - : "64001F"; - fmtp = $"a=fmtp:96 packetization-mode=1;sprop-parameter-sets={spsB64},{ppsB64};profile-level-id={pli}\r\n"; - } - } - - LogUtils.debug($"[RTSP] SDP codec=H264 hasSps={cachedSps != null} hasPps={cachedPps != null}"); - return - "v=0\r\n" + - "o=- 1 1 IN IP4 0.0.0.0\r\n" + - "s=V380 Live\r\n" + - "t=0 0\r\n" + - "a=recvonly\r\n" + - "m=video 0 RTP/AVP 96\r\n" + - "a=rtpmap:96 H264/90000\r\n" + - fmtp + - "a=control:trackID=0\r\n" + - "m=audio 0 RTP/AVP 8\r\n" + - "a=rtpmap:8 PCMA/8000/1\r\n" + - "a=control:trackID=1\r\n"; - } - - public void Dispose() - { - running = false; - try { listener?.Stop(); } catch { } - foreach (var s in sessions.Values) s.Close(); - transcoder.Dispose(); - } - } +namespace V380Decoder.src { + public class RtspServer { + private readonly int port; + private readonly H264Transcoder transcoder; + private TcpListener listener; + private Thread acceptThread; + private volatile bool running; + private readonly ConcurrentDictionary sessions = new(); + private int nextId; + private byte[] cachedSps; + private byte[] cachedPps; + private byte[] cachedVps; + private byte[] cachedHevcSps; + private byte[] cachedHevcPps; + private readonly object sdpLock = new(); + + public RtspServer(int port) { + this.port = port; + transcoder = new H264Transcoder(PushVideoDirect); + } + + public void Start() { + listener = new TcpListener(IPAddress.Any, port); + listener.Start(10); + running = true; + acceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "rtsp-accept" }; + acceptThread.Start(); + Console.Error.WriteLine($"[RTSP] rtsp://{NetworkHelper.GetLocalIPAddress()}:{port}/live"); + } + + void AcceptLoop() { + while (running) { + try { + var tcp = listener.AcceptTcpClient(); + tcp.NoDelay = true; + int id = Interlocked.Increment(ref nextId); + var s = new RtspSession(id, tcp, this); + sessions[id] = s; + s.Start(); + s.OnClose += () => sessions.TryRemove(id, out _); + } catch { } + } + } + + public void PushVideo(FrameData f) { + if (f.Codec == VideoCodec.H265 && transcoder.IsAvailable) { + transcoder.PushFrame(f); + return; + } + PushVideoDirect(f); + } + + private void PushVideoDirect(FrameData f) { + if (f.Codec == VideoCodec.H265) { + CacheHevcParameterSets(f.Payload); + } else { + CacheSpsPps(f.Payload); + } + foreach (var s in sessions.Values) s.PushVideo(f); + } + + public void PushAudio(FrameData f) { + foreach (var s in sessions.Values) s.PushAudio(f); + } + + void CacheSpsPps(byte[] data) { + lock (sdpLock) { + if (cachedSps != null && cachedPps != null) return; + ParseNals(data, VideoCodec.H264, (nalType, nal) => { + if (nalType == 7 && cachedSps == null) cachedSps = nal; + if (nalType == 8 && cachedPps == null) cachedPps = nal; + }); + } + } + + void CacheHevcParameterSets(byte[] data) { + lock (sdpLock) { + if (cachedVps != null && cachedHevcSps != null && cachedHevcPps != null) return; + ParseNals(data, VideoCodec.H265, (nalType, nal) => { + if (nalType == 32 && cachedVps == null) cachedVps = nal; + if (nalType == 33 && cachedHevcSps == null) cachedHevcSps = nal; + if (nalType == 34 && cachedHevcPps == null) cachedHevcPps = nal; + }); + } + } + + internal static void ParseNals(byte[] data, VideoCodec codec, Action cb) { + int i = 0; + int len = data.Length; + while (i < len) { + int sc = FindStartCode(data, i); + if (sc < 0) break; + int scLen = (sc + 3 < len && data[sc + 2] == 1) ? 3 : 4; + int nalStart = sc + scLen; + if (nalStart >= len) break; + int next = FindStartCode(data, nalStart); + int nalEnd = next < 0 ? len : next; + int nalType = codec == VideoCodec.H265 ? (data[nalStart] >> 1) & 0x3F : data[nalStart] & 0x1F; + var nal = new byte[nalEnd - nalStart]; + Array.Copy(data, nalStart, nal, 0, nal.Length); + cb(nalType, nal); + i = nalEnd; + } + } + + static int FindStartCode(byte[] d, int from) { + for (int i = from; i + 3 < d.Length; i++) { + if (d[i] == 0 && d[i + 1] == 0) { + if (d[i + 2] == 1) return i; + if (d[i + 2] == 0 && i + 3 < d.Length && d[i + 3] == 1) return i; + } + } + return -1; + } + + public string BuildSdp() { + string fmtpH264 = ""; + string fmtpH265 = ""; + string rtpmap = "a=rtpmap:96 H264/90000\r\n"; + lock (sdpLock) { + if (cachedSps != null && cachedPps != null) { + string spsB64 = Convert.ToBase64String(cachedSps); + string ppsB64 = Convert.ToBase64String(cachedPps); + string pli = cachedSps.Length >= 3 ? $"{cachedSps[0]:X2}{cachedSps[1]:X2}{cachedSps[2]:X2}" : "64001F"; + fmtpH264 = $"a=fmtp:96 packetization-mode=1;sprop-parameter-sets={spsB64},{ppsB64};profile-level-id={pli}\r\n"; + } + if (cachedVps != null && cachedHevcSps != null && cachedHevcPps != null) { + string vpsB64 = Convert.ToBase64String(cachedVps); + string spsB64 = Convert.ToBase64String(cachedHevcSps); + string ppsB64 = Convert.ToBase64String(cachedHevcPps); + fmtpH265 = $"a=fmtp:97 profile-id=1;sprop-vps={vpsB64};sprop-sps={spsB64};sprop-pps={ppsB64}\r\n"; + rtpmap = "a=rtpmap:96 H264/90000\r\na=rtpmap:97 H265/90000\r\n"; + } + } + LogUtils.debug($"[RTSP] SDP h264={!string.IsNullOrEmpty(fmtpH264)} hevc={!string.IsNullOrEmpty(fmtpH265)}"); + return "v=0\r\n" + + "o=- 1 1 IN IP4 0.0.0.0\r\n" + + "s=V380 Live\r\n" + + "t=0 0\r\n" + + "a=recvonly\r\n" + + "m=video 0 RTP/AVP 96 97\r\n" + + rtpmap + + fmtpH264 + + fmtpH265 + + "a=control:trackID=0\r\n" + + "m=audio 0 RTP/AVP 8\r\n" + + "a=rtpmap:8 PCMA/8000/1\r\n" + + "a=control:trackID=1\r\n"; + } + + public byte[] GetHevcParameterSets() { + lock (sdpLock) { + if (cachedVps == null || cachedHevcSps == null || cachedHevcPps == null) return null; + int len = cachedVps.Length + cachedHevcSps.Length + cachedHevcPps.Length; + var result = new byte[len]; + int offset = 0; + Array.Copy(cachedVps, 0, result, offset, cachedVps.Length); + offset += cachedVps.Length; + Array.Copy(cachedHevcSps, 0, result, offset, cachedHevcSps.Length); + offset += cachedHevcSps.Length; + Array.Copy(cachedHevcPps, 0, result, offset, cachedHevcPps.Length); + return result; + } + } + + public void Dispose() { + running = false; + try { listener?.Stop(); } catch { } + foreach (var s in sessions.Values) s.Close(); + transcoder.Dispose(); + } + } } diff --git a/src/RtspSession.cs b/src/RtspSession.cs index ff431c2..bd6aed4 100644 --- a/src/RtspSession.cs +++ b/src/RtspSession.cs @@ -1,246 +1,236 @@ using System.Net.Sockets; using System.Text; - -namespace V380Decoder.src -{ - public class RtspSession - { - private readonly int id; - private readonly TcpClient tcp; - private readonly NetworkStream ns; - private readonly RtspServer server; - private Thread readThread; - private volatile bool playing; - private volatile bool alive = true; - - private byte videoCh = 0; - private byte audioCh = 2; - - private ushort videoSeq; - private ushort audioSeq; - private uint videoSsrc = (uint)new Random().Next(); - private uint audioSsrc = (uint)new Random().Next(); - private uint generatedVideoTimestamp; - - public event Action OnClose; - - public RtspSession(int id, TcpClient tcp, RtspServer server) - { - this.id = id; - this.tcp = tcp; - this.server = server; - ns = tcp.GetStream(); - } - - public void Start() - { - readThread = new Thread(ReadLoop) { IsBackground = true, Name = $"rtsp-{id}" }; - readThread.Start(); - } - - public void Close() - { - alive = false; - playing = false; - try { tcp.Close(); } catch { } - OnClose?.Invoke(); - } - - void ReadLoop() - { - var sb = new StringBuilder(); - var buf = new byte[4096]; - try - { - while (alive) - { - int n = ns.Read(buf, 0, buf.Length); - if (n <= 0) break; - sb.Append(Encoding.ASCII.GetString(buf, 0, n)); - string raw = sb.ToString(); - int end; - while ((end = raw.IndexOf("\r\n\r\n", StringComparison.Ordinal)) >= 0) - { - string req = raw[..(end + 4)]; - raw = raw[(end + 4)..]; - HandleRequest(req); - } - sb.Clear(); - sb.Append(raw); - } - } - catch { } - finally { Close(); } - } - - void HandleRequest(string req) - { - string[] lines = req.Split("\r\n", StringSplitOptions.None); - if (lines.Length == 0) return; - - string method = lines[0].Split(' ')[0]; - string url = lines[0].Split(' ').ElementAtOrDefault(1) ?? ""; - string cseq = lines.FirstOrDefault(l => l.StartsWith("CSeq:", StringComparison.OrdinalIgnoreCase)) - ?.Split(':', 2)[1].Trim() ?? "0"; - string transport = lines.FirstOrDefault(l => l.StartsWith("Transport:", StringComparison.OrdinalIgnoreCase)) ?? ""; - - switch (method) - { - case "OPTIONS": - Reply(cseq, "Public: OPTIONS,DESCRIBE,SETUP,PLAY,TEARDOWN"); - break; - - case "DESCRIBE": - { - string sdp = server.BuildSdp(); - byte[] body = Encoding.ASCII.GetBytes(sdp); - Send($"RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\nContent-Type: application/sdp\r\nContent-Length: {body.Length}\r\n\r\n{sdp}"); - break; - } - - case "SETUP": - { - bool isAudio = url.Contains("trackID=1"); - byte ch = (byte)(isAudio ? 2 : 0); - var m = System.Text.RegularExpressions.Regex.Match(transport, @"interleaved=(\d+)-(\d+)"); - if (m.Success) ch = byte.Parse(m.Groups[1].Value); - - if (isAudio) audioCh = ch; - else videoCh = ch; - - Reply(cseq, - $"Transport: RTP/AVP/TCP;unicast;interleaved={ch}-{ch + 1}", - "Session: 1"); - break; - } - - case "PLAY": - Reply(cseq, - "Session: 1", - $"RTP-Info: url={url}/trackID=0;seq={videoSeq},url={url}/trackID=1;seq={audioSeq}"); - playing = true; - Console.Error.WriteLine($"[RTSP#{id}] playing"); - break; - - case "TEARDOWN": - Reply(cseq, "Session: 1"); - Close(); - break; - - default: - Send($"RTSP/1.0 501 Not Implemented\r\nCSeq: {cseq}\r\n\r\n"); - break; - } - } - - void Reply(string cseq, params string[] headers) - { - var sb = new StringBuilder(); - sb.Append($"RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\n"); - foreach (var h in headers) sb.Append(h + "\r\n"); - sb.Append("\r\n"); - Send(sb.ToString()); - } - - void Send(string s) - { - try - { - byte[] b = Encoding.ASCII.GetBytes(s); - lock (ns) { ns.Write(b, 0, b.Length); ns.Flush(); } - } - catch { alive = false; } - } - - public void PushVideo(FrameData f) - { - if (!playing) return; - - uint rts = f.Timestamp > 0 - ? (uint)(f.Timestamp * 90) - : (generatedVideoTimestamp += 3600); - - RtspServer.ParseNals(f.Payload, VideoCodec.H264, (nalType, nal) => - { - const int mtu = 1400; - if (nal.Length <= mtu) - { - SendRtp(videoCh, 96, videoSeq++, rts, videoSsrc, nal, 0, nal.Length, marker: true); - return; - } - - SendH264Fragmented(nal, rts, mtu); - }); - } - - void SendH264Fragmented(byte[] nal, uint rts, int mtu) - { - byte nalHdr = nal[0]; - byte fuInd = (byte)((nalHdr & 0xE0) | 28); - int offset = 1; - bool first = true; - - while (offset < nal.Length) - { - int chunk = Math.Min(mtu - 2, nal.Length - offset); - bool last = offset + chunk >= nal.Length; - - byte fuHdr = (byte)(nalHdr & 0x1F); - if (first) fuHdr |= 0x80; - if (last) fuHdr |= 0x40; - - var frag = new byte[2 + chunk]; - frag[0] = fuInd; - frag[1] = fuHdr; - Array.Copy(nal, offset, frag, 2, chunk); - - SendRtp(videoCh, 96, videoSeq++, rts, videoSsrc, frag, 0, frag.Length, marker: last); - offset += chunk; - first = false; - } - } - - public void PushAudio(FrameData f) - { - if (!playing) return; - - uint rts = (uint)(f.Timestamp * 8); - const int chunkSize = 160; - for (int off = 0; off < f.Payload.Length; off += chunkSize) - { - int len = Math.Min(chunkSize, f.Payload.Length - off); - SendRtp(audioCh, 8, audioSeq++, rts, audioSsrc, f.Payload, off, len, marker: false); - rts += (uint)len; - } - } - - void SendRtp(byte channel, byte pt, ushort seq, uint ts, uint ssrc, - byte[] payload, int offset, int length, bool marker) - { - var rtp = new byte[12 + length]; - rtp[0] = 0x80; - rtp[1] = (byte)((marker ? 0x80 : 0) | (pt & 0x7F)); - rtp[2] = (byte)(seq >> 8); - rtp[3] = (byte)seq; - rtp[4] = (byte)(ts >> 24); - rtp[5] = (byte)(ts >> 16); - rtp[6] = (byte)(ts >> 8); - rtp[7] = (byte)ts; - rtp[8] = (byte)(ssrc >> 24); - rtp[9] = (byte)(ssrc >> 16); - rtp[10] = (byte)(ssrc >> 8); - rtp[11] = (byte)ssrc; - Array.Copy(payload, offset, rtp, 12, length); - - var frame = new byte[4 + rtp.Length]; - frame[0] = 0x24; - frame[1] = channel; - frame[2] = (byte)(rtp.Length >> 8); - frame[3] = (byte)rtp.Length; - Array.Copy(rtp, 0, frame, 4, rtp.Length); - - try { lock (ns) { ns.Write(frame, 0, frame.Length); ns.Flush(); } } - catch { alive = false; } - } - } +namespace V380Decoder.src { + public class RtspSession { + private readonly int id; + private readonly TcpClient tcp; + private readonly NetworkStream ns; + private readonly RtspServer server; + private Thread readThread; + private volatile bool playing; + private volatile bool alive = true; + private byte videoCh = 0; + private byte audioCh = 2; + private ushort videoSeq; + private ushort audioSeq; + private uint videoSsrc = (uint)new Random().Next(); + private uint audioSsrc = (uint)new Random().Next(); + private uint generatedVideoTimestamp; + + public event Action OnClose; + + public RtspSession(int id, TcpClient tcp, RtspServer server) { + this.id = id; + this.tcp = tcp; + this.server = server; + ns = tcp.GetStream(); + } + + public void Start() { + readThread = new Thread(ReadLoop) { IsBackground = true, Name = $"rtsp-{id}" }; + readThread.Start(); + } + + public void Close() { + alive = false; + playing = false; + try { tcp.Close(); } catch { } + OnClose?.Invoke(); + } + + void ReadLoop() { + var sb = new StringBuilder(); + var buf = new byte[4096]; + try { + while (alive) { + int n = ns.Read(buf, 0, buf.Length); + if (n <= 0) break; + sb.Append(Encoding.ASCII.GetString(buf, 0, n)); + string raw = sb.ToString(); + int end; + while ((end = raw.IndexOf("\r\n\r\n", StringComparison.Ordinal)) >= 0) { + string req = raw[..(end + 4)]; + raw = raw[(end + 4)..]; + HandleRequest(req); + } + sb.Clear(); + sb.Append(raw); + } + } catch { } + finally { Close(); } + } + + void HandleRequest(string req) { + string[] lines = req.Split("\r\n", StringSplitOptions.None); + if (lines.Length == 0) return; + string method = lines[0].Split(' ')[0]; + string url = lines[0].Split(' ').ElementAtOrDefault(1) ?? ""; + string cseq = lines.FirstOrDefault(l => l.StartsWith("CSeq:", StringComparison.OrdinalIgnoreCase)) + ?.Split(':', 2)[1].Trim() ?? "0"; + string transport = lines.FirstOrDefault(l => l.StartsWith("Transport:", StringComparison.OrdinalIgnoreCase)) ?? ""; + switch (method) { + case "OPTIONS": + Reply(cseq, "Public: OPTIONS,DESCRIBE,SETUP,PLAY,TEARDOWN"); + break; + case "DESCRIBE": { + string sdp = server.BuildSdp(); + byte[] body = Encoding.ASCII.GetBytes(sdp); + Send($"RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\nContent-Type: application/sdp\r\nContent-Length: {body.Length}\r\n\r\n{sdp}"); + break; + } + case "SETUP": { + bool isAudio = url.Contains("trackID=1"); + byte ch = (byte)(isAudio ? 2 : 0); + var m = System.Text.RegularExpressions.Regex.Match(transport, @"interleaved=(\d+)-(\d+)"); + if (m.Success) ch = byte.Parse(m.Groups[1].Value); + if (isAudio) audioCh = ch; + else videoCh = ch; + Reply(cseq, $"Transport: RTP/AVP/TCP;unicast;interleaved={ch}-{ch + 1}", "Session: 1"); + break; + } + case "PLAY": + Reply(cseq, "Session: 1", $"RTP-Info: url={url}/trackID=0;seq={videoSeq},url={url}/trackID=1;seq={audioSeq}"); + playing = true; + Console.Error.WriteLine($"[RTSP#{id}] playing"); + break; + case "TEARDOWN": + Reply(cseq, "Session: 1"); + Close(); + break; + default: + Send($"RTSP/1.0 501 Not Implemented\r\nCSeq: {cseq}\r\n\r\n"); + break; + } + } + + void Reply(string cseq, params string[] headers) { + var sb = new StringBuilder(); + sb.Append($"RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\n"); + foreach (var h in headers) sb.Append(h + "\r\n"); + sb.Append("\r\n"); + Send(sb.ToString()); + } + + void Send(string s) { + try { + byte[] b = Encoding.ASCII.GetBytes(s); + lock (ns) { + ns.Write(b, 0, b.Length); + ns.Flush(); + } + } catch { alive = false; } + } + + public void PushVideo(FrameData f) { + if (!playing) return; + uint rts = f.Timestamp > 0 ? (uint)(f.Timestamp * 90) : (generatedVideoTimestamp += 3600); + byte[] payload = f.Payload; + if (f.Codec == VideoCodec.H265) { + byte[] paramSets = server.GetHevcParameterSets(); + if (paramSets != null) { + payload = new byte[paramSets.Length + payload.Length]; + Buffer.BlockCopy(paramSets, 0, payload, 0, paramSets.Length); + Buffer.BlockCopy(f.Payload, 0, payload, paramSets.Length, f.Payload.Length); + } + } + RtspServer.ParseNals(payload, f.Codec, (nalType, nal) => { + const int mtu = 1400; + if (nal.Length <= mtu) { + if (f.Codec == VideoCodec.H265) { + SendRtp(videoCh, 97, videoSeq++, rts, videoSsrc, nal, 0, nal.Length, marker: true); + } else { + SendRtp(videoCh, 96, videoSeq++, rts, videoSsrc, nal, 0, nal.Length, marker: true); + } + return; + } + if (f.Codec == VideoCodec.H265) { + SendHevcFragmented(nal, rts, mtu); + } else { + SendH264Fragmented(nal, rts, mtu); + } + }); + } + + void SendH264Fragmented(byte[] nal, uint rts, int mtu) { + byte nalHdr = nal[0]; + byte fuInd = (byte)((nalHdr & 0xE0) | 28); + int offset = 1; + bool first = true; + while (offset < nal.Length) { + int chunk = Math.Min(mtu - 2, nal.Length - offset); + bool last = offset + chunk >= nal.Length; + byte fuHdr = (byte)(nalHdr & 0x1F); + if (first) fuHdr |= 0x80; + if (last) fuHdr |= 0x40; + var frag = new byte[2 + chunk]; + frag[0] = fuInd; + frag[1] = fuHdr; + Array.Copy(nal, offset, frag, 2, chunk); + SendRtp(videoCh, 96, videoSeq++, rts, videoSsrc, frag, 0, frag.Length, marker: last); + offset += chunk; + first = false; + } + } + + void SendHevcFragmented(byte[] nal, uint rts, int mtu) { + byte nalHdr = nal[0]; + byte fuInd = (byte)((nalHdr & 0x81) | 49); + int offset = 1; + bool first = true; + while (offset < nal.Length) { + int chunk = Math.Min(mtu - 2, nal.Length - offset); + bool last = offset + chunk >= nal.Length; + byte fuHdr = (byte)((nalHdr >> 1) & 0x3F); + if (first) fuHdr |= 0x80; + if (last) fuHdr |= 0x40; + var frag = new byte[2 + chunk]; + frag[0] = fuInd; + frag[1] = fuHdr; + Array.Copy(nal, offset, frag, 2, chunk); + SendRtp(videoCh, 97, videoSeq++, rts, videoSsrc, frag, 0, frag.Length, marker: last); + offset += chunk; + first = false; + } + } + + public void PushAudio(FrameData f) { + if (!playing) return; + uint rts = (uint)(f.Timestamp * 8); + const int chunkSize = 160; + for (int off = 0; off < f.Payload.Length; off += chunkSize) { + int len = Math.Min(chunkSize, f.Payload.Length - off); + SendRtp(audioCh, 8, audioSeq++, rts, audioSsrc, f.Payload, off, len, marker: false); + rts += (uint)len; + } + } + + void SendRtp(byte channel, byte pt, ushort seq, uint ts, uint ssrc, byte[] payload, int offset, int length, bool marker) { + var rtp = new byte[12 + length]; + rtp[0] = 0x80; + rtp[1] = (byte)((marker ? 0x80 : 0) | (pt & 0x7F)); + rtp[2] = (byte)(seq >> 8); + rtp[3] = (byte)seq; + rtp[4] = (byte)(ts >> 24); + rtp[5] = (byte)(ts >> 16); + rtp[6] = (byte)(ts >> 8); + rtp[7] = (byte)ts; + rtp[8] = (byte)(ssrc >> 24); + rtp[9] = (byte)(ssrc >> 16); + rtp[10] = (byte)(ssrc >> 8); + rtp[11] = (byte)ssrc; + Array.Copy(payload, offset, rtp, 12, length); + var frame = new byte[4 + rtp.Length]; + frame[0] = 0x24; + frame[1] = channel; + frame[2] = (byte)(rtp.Length >> 8); + frame[3] = (byte)rtp.Length; + Array.Copy(rtp, 0, frame, 4, rtp.Length); + try { + lock (ns) { + ns.Write(frame, 0, frame.Length); + ns.Flush(); + } + } catch { alive = false; } + } + } } diff --git a/src/SnapshotManager.cs b/src/SnapshotManager.cs index d7816eb..1920672 100644 --- a/src/SnapshotManager.cs +++ b/src/SnapshotManager.cs @@ -68,6 +68,8 @@ private bool IsFFmpegAvailable() CreateNoWindow = true } }; + // Ensure ffmpeg is found in PATH + process.StartInfo.EnvironmentVariables["PATH"] = "/usr/local/bin:/usr/bin:/bin"; process.Start(); bool exited = process.WaitForExit(2000); diff --git a/src/V380Client.cs b/src/V380Client.cs index ff915bf..3772ac1 100644 --- a/src/V380Client.cs +++ b/src/V380Client.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Net.Sockets; using System.Security.Cryptography; using System.Text; @@ -21,6 +22,8 @@ public class V380Client : IDisposable private int frameWidth = 1280; private int frameheight = 720; private byte[] aesKey = new byte[16]; + private StreamWriter? frameDump; + private bool frameDumpEnabled = false; private bool needReconnect = false; public V380Client(string ip, int port, uint deviceId, string username, string password, SourceStream source, OutputMode mode, int streamQuality) @@ -39,7 +42,19 @@ public V380Client(string ip, int port, uint deviceId, string username, string pa public void Run(RtspServer rtsp, CancellationToken ct) { - while (!ct.IsCancellationRequested) + try + { + frameDumpEnabled = File.Exists("/home/favour/camera/frame_dump.bin"); + if (frameDumpEnabled) + { + frameDump = new StreamWriter(new FileStream("/home/favour/camera/frame_dump.log", FileMode.Append, FileAccess.Write, FileShare.Read)); + frameDump.AutoFlush = true; + LogUtils.debug("[DUMP] frame dump ENABLED - writing to /home/favour/camera/frame_dump.bin and .log"); + } + } + catch { } + + while (!ct.IsCancellationRequested) { try { @@ -397,20 +412,28 @@ public void ReceiveFrames(OutputMode mode, RtspServer rtsp, CancellationToken ct if (!HandleAsMediaFrame(frameStartType, full, needDecrypt, mode, rtsp, stdout)) { - Console.Error.WriteLine($"[FRAME] unknown type=0x{frameStartType:X2} len={full.Length}"); + Console.Error.WriteLine($"[FRAME] unknown type=0x{frameStartType:X2} len={full.Length} head={BitConverter.ToString(full, 0, Math.Min(32, full.Length))}"); } } } catch (OperationCanceledException) { - Console.Error.WriteLine("[RECV] Operation cancelled"); + Console.Error.WriteLine("[RECV] Operation cancelled"); + frameDump?.WriteLine($"[{DateTime.Now:HH:mm:ss}] Operation cancelled"); + frameDump?.Flush(); } catch (Exception ex) { - Console.Error.WriteLine($"[RECV] {ex.Message}"); - Console.Error.WriteLine($"[RECV] {ex.StackTrace}"); + Console.Error.WriteLine($"[RECV] {ex.Message}"); + Console.Error.WriteLine($"[RECV] {ex.StackTrace}"); + frameDump?.WriteLine($"[{DateTime.Now:HH:mm:ss}] ERROR: {ex.Message}"); + frameDump?.Flush(); + } + finally + { + frameDump?.Dispose(); + } } - } private bool HandleAsMediaFrame(byte rawType, byte[] full, bool needDecrypt, OutputMode mode, RtspServer rtsp, Stream stdout) { @@ -488,6 +511,64 @@ private bool HandleAsMediaFrame(byte rawType, byte[] full, bool needDecrypt, Out return true; } + if (rawType == 0x18 || rawType == 0x28 || rawType == 0x29) + { + const int headerSize = 16; + if (full.Length <= headerSize) return false; + byte[] payload = new byte[full.Length - headerSize]; + Array.Copy(full, headerSize, payload, 0, payload.Length); + + // dump raw frame before decryption + if (frameDumpEnabled) + { + frameDump?.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] V rawType=0x{rawType:X2} len={full.Length} raw_head={BitConverter.ToString(full, 0, Math.Min(32, full.Length))}"); + frameDump?.Flush(); + var binPath = "/home/favour/camera/frame_dump.bin"; + using var bfs = new FileStream(binPath, FileMode.Append, FileAccess.Write, FileShare.Read); + bfs.Write(full, 0, full.Length); + } + + if (needDecrypt && payload.Length >= 16) + { + DecryptVideoFrame(payload, payload.Length); + } + + // dump payload after decryption + if (frameDumpEnabled) + { + frameDump?.WriteLine($" -> decrypted len={payload.Length} head={BitConverter.ToString(payload, 0, Math.Min(64, payload.Length))}"); + frameDump?.Flush(); + var binPath = "/home/favour/camera/frame_dump.bin"; + using var bfs = new FileStream(binPath, FileMode.Append, FileAccess.Write, FileShare.Read); + bfs.Write(payload, 0, payload.Length); + } + + bool keyFrame = IsKeyVideoFrame(rawType, outerFrameType, payload); + if (mode == OutputMode.Rtsp) + { + snapshotManager.UpdateFrame(payload, frameWidth, frameheight, keyFrame, outerTimestamp); + } + var vFrame = new FrameData + { + RawType = rawType, + FrameId = outerFrameId, + FrameType = outerFrameType, + FrameRate = outerFrameRate, + Timestamp = outerTimestamp, + Codec = VideoCodec.H265, + Payload = payload + }; + if (mode == OutputMode.Video) + { + stdout?.Write(payload, 0, payload.Length); + stdout?.Flush(); + } + else if (mode == OutputMode.Rtsp) + { + rtsp?.PushVideo(vFrame); + } + return true; + } if (!TryExtractVideoPayload(full, needDecrypt, out var normalizedPayload)) { LogUtils.debug($"[FRAME] unclassified type=0x{rawType:X2} len={full.Length} head={BitConverter.ToString(full, 0, Math.Min(16, full.Length))}"); diff --git a/src/WebServer.cs b/src/WebServer.cs index 0e20759..800b607 100644 --- a/src/WebServer.cs +++ b/src/WebServer.cs @@ -27,122 +27,123 @@ public WebServer(int httpPort, int rtspPort, V380Client client, bool enableApi, } public void Start() - { - string ipAddress = NetworkHelper.GetLocalIPAddress(); - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseUrls($"http://*:{httpPort}"); - - builder.Logging.ClearProviders(); - builder.Services.ConfigureHttpJsonOptions(options => - { - options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); - }); - - app = builder.Build(); - - Console.Error.WriteLine($"[SNAPSHOT] http://{ipAddress}:{httpPort}/snapshot"); - app.MapGet("/snapshot", (HttpContext ctx) => - { - var jpeg = client.snapshotManager.GetSnapshot(timeoutMs: 5000); - - if (jpeg == null || jpeg.Length == 0) { - return Results.Problem( - "No snapshot available. Ensure stream is running", - statusCode: 503 - ); - } + string ipAddress = NetworkHelper.GetLocalIPAddress(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls($"http://*:{httpPort}"); - ctx.Response.Headers["Cache-Control"] = "no-cache"; + builder.Logging.ClearProviders(); + builder.Services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); + }); - return Results.File(jpeg, "image/jpeg"); - }); + app = builder.Build(); - Console.Error.WriteLine($"[MJPEG] http://{ipAddress}:{httpPort}/stream.mjpg"); - app.MapGet("/stream.mjpg", async (HttpContext ctx) => - { - ctx.Response.Headers["Cache-Control"] = "no-cache"; - ctx.Response.Headers["Pragma"] = "no-cache"; - ctx.Response.Headers["Connection"] = "close"; - ctx.Response.ContentType = "multipart/x-mixed-replace; boundary=frame"; + Console.Error.WriteLine($"[SNAPSHOT] http://{ipAddress}:{httpPort}/snapshot"); + app.MapGet("/snapshot", (HttpContext ctx) => + { + var jpeg = client.snapshotManager.GetSnapshot(timeoutMs: 5000); - long lastVersion = -1; + if (jpeg == null || jpeg.Length == 0) + { + return Results.Problem( + "No snapshot available. Ensure stream is running", + statusCode: 503 + ); + } - while (!ctx.RequestAborted.IsCancellationRequested) - { - byte[] jpeg; - long version; + ctx.Response.Headers["Cache-Control"] = "no-cache"; - if (!client.snapshotManager.TryGetCachedSnapshot(out jpeg, out version, out _)) - { - jpeg = client.snapshotManager.GetSnapshot(timeoutMs: 1000); - client.snapshotManager.TryGetCachedSnapshot(out jpeg, out version, out _); - } + return Results.File(jpeg, "image/jpeg"); + }); - if (version != lastVersion && jpeg != null && jpeg.Length > 0) + Console.Error.WriteLine($"[MJPEG] http://{ipAddress}:{httpPort}/stream.mjpg"); + app.MapGet("/stream.mjpg", async (HttpContext ctx) => + { + ctx.Response.Headers["Cache-Control"] = "no-cache"; + ctx.Response.Headers["Pragma"] = "no-cache"; + ctx.Response.Headers["Connection"] = "close"; + ctx.Response.ContentType = "multipart/x-mixed-replace; boundary=frame"; + + long lastVersion = -1; + + while (!ctx.RequestAborted.IsCancellationRequested) + { + byte[] jpeg; + long version; + + if (!client.snapshotManager.TryGetCachedSnapshot(out jpeg, out version, out _)) + { + jpeg = client.snapshotManager.GetSnapshot(timeoutMs: 1000); + client.snapshotManager.TryGetCachedSnapshot(out jpeg, out version, out _); + } + + if (version != lastVersion && jpeg != null && jpeg.Length > 0) + { + await ctx.Response.WriteAsync("--frame\r\n"); + await ctx.Response.WriteAsync("Content-Type: image/jpeg\r\n"); + await ctx.Response.WriteAsync($"Content-Length: {jpeg.Length}\r\n\r\n"); + await ctx.Response.Body.WriteAsync(jpeg, 0, jpeg.Length, ctx.RequestAborted); + await ctx.Response.WriteAsync("\r\n"); + await ctx.Response.Body.FlushAsync(ctx.RequestAborted); + lastVersion = version; + } + + await Task.Delay(66, ctx.RequestAborted); + } + }); + + if (enableApi) { - await ctx.Response.WriteAsync("--frame\r\n"); - await ctx.Response.WriteAsync("Content-Type: image/jpeg\r\n"); - await ctx.Response.WriteAsync($"Content-Length: {jpeg.Length}\r\n\r\n"); - await ctx.Response.Body.WriteAsync(jpeg, 0, jpeg.Length, ctx.RequestAborted); - await ctx.Response.WriteAsync("\r\n"); - await ctx.Response.Body.FlushAsync(ctx.RequestAborted); - lastVersion = version; + Console.Error.WriteLine($"[WEB] http://{ipAddress}:{httpPort}"); + Console.Error.WriteLine($"[API] http://{ipAddress}:{httpPort}/api/"); + + app.MapGet("/", () => Results.Content(WebPage.GetHtml(), "text/html")); + + app.MapPost("/api/ptz/right", () => { client.PtzRight(); LogUtils.debug("[API] PTZ Right"); Results.Ok(); }); + app.MapPost("/api/ptz/left", () => { client.PtzLeft(); LogUtils.debug("[API] PTZ Left"); Results.Ok(); }); + app.MapPost("/api/ptz/up", () => { client.PtzUp(); LogUtils.debug("[API] PTZ Up"); Results.Ok(); }); + app.MapPost("/api/ptz/down", () => { client.PtzDown(); LogUtils.debug("[API] PTZ Down"); Results.Ok(); }); + app.MapPost("/api/ptz/stop", () => { client.PtzStop(); LogUtils.debug("[API] PTZ Stop"); Results.Ok(); }); + + app.MapPost("/api/light/on", () => { client.LightOn(); LogUtils.debug("[API] Light On"); Results.Ok(); }); + app.MapPost("/api/light/off", () => { client.LightOff(); LogUtils.debug("[API] Light Off"); Results.Ok(); }); + app.MapPost("/api/light/auto", () => { client.LightAuto(); LogUtils.debug("[API] Light Auto"); Results.Ok(); }); + + app.MapPost("/api/image/color", () => { client.ImageColor(); LogUtils.debug("[API] Image Color"); Results.Ok(); }); + app.MapPost("/api/image/bw", () => { client.ImageBW(); LogUtils.debug("[API] Image B&W"); Results.Ok(); }); + app.MapPost("/api/image/auto", () => { client.ImageAuto(); LogUtils.debug("[API] Image Auto"); Results.Ok(); }); + app.MapPost("/api/image/flip", () => { client.ImageFlip(); LogUtils.debug("[API] Image Flip"); Results.Ok(); }); + + app.MapGet("/api/status", () => Results.Ok(new StatusResponse + { + status = "running", + timestamp = DateTime.Now + })); } - await Task.Delay(66, ctx.RequestAborted); - } - }); - - if (enableApi) - { - Console.Error.WriteLine($"[WEB] http://{ipAddress}:{httpPort}"); - Console.Error.WriteLine($"[API] http://{ipAddress}:{httpPort}/api/"); - - app.MapGet("/", () => Results.Content(WebPage.GetHtml(), "text/html")); - - app.MapPost("/api/ptz/right", () => { client.PtzRight(); LogUtils.debug("[API] PTZ Right"); Results.Ok(); }); - app.MapPost("/api/ptz/left", () => { client.PtzLeft(); LogUtils.debug("[API] PTZ Left"); Results.Ok(); }); - app.MapPost("/api/ptz/up", () => { client.PtzUp(); LogUtils.debug("[API] PTZ Up"); Results.Ok(); }); - app.MapPost("/api/ptz/down", () => { client.PtzDown(); LogUtils.debug("[API] PTZ Down"); Results.Ok(); }); - app.MapPost("/api/ptz/stop", () => { client.PtzStop(); LogUtils.debug("[API] PTZ Stop"); Results.Ok(); }); - - app.MapPost("/api/light/on", () => { client.LightOn(); LogUtils.debug("[API] Light On"); Results.Ok(); }); - app.MapPost("/api/light/off", () => { client.LightOff(); LogUtils.debug("[API] Light Off"); Results.Ok(); }); - app.MapPost("/api/light/auto", () => { client.LightAuto(); LogUtils.debug("[API] Light Auto"); Results.Ok(); }); - - app.MapPost("/api/image/color", () => { client.ImageColor(); LogUtils.debug("[API] Image Color"); Results.Ok(); }); - app.MapPost("/api/image/bw", () => { client.ImageBW(); LogUtils.debug("[API] Image B&W"); Results.Ok(); }); - app.MapPost("/api/image/auto", () => { client.ImageAuto(); LogUtils.debug("[API] Image Auto"); Results.Ok(); }); - app.MapPost("/api/image/flip", () => { client.ImageFlip(); LogUtils.debug("[API] Image Flip"); Results.Ok(); }); - - app.MapGet("/api/status", () => Results.Ok(new StatusResponse - { - status = "running", - timestamp = DateTime.Now - })); - } - - if (enableOnvif) - { - Console.Error.WriteLine($"[ONVIF] http://{ipAddress}:{httpPort}/onvif/device_service"); - - app.MapPost("/onvif/device_service", async (HttpContext ctx) => - await HandleOnvif(ctx)); + if (enableOnvif) + { + Console.Error.WriteLine($"[ONVIF] http://{ipAddress}:{httpPort}/onvif/device_service"); - app.MapPost("/onvif/media_service", async (HttpContext ctx) => - await HandleOnvif(ctx)); + app.MapPost("/onvif/device_service", async (HttpContext ctx) => + await HandleOnvif(ctx)); - app.MapPost("/onvif/ptz_service", async (HttpContext ctx) => - await HandleOnvif(ctx)); + app.MapPost("/onvif/media_service", async (HttpContext ctx) => + await HandleOnvif(ctx)); - app.MapPost("/onvif/imaging_service", async (HttpContext ctx) => - await HandleOnvif(ctx)); - } + app.MapPost("/onvif/ptz_service", async (HttpContext ctx) => + await HandleOnvif(ctx)); - runTask = app.RunAsync(); + app.MapPost("/onvif/imaging_service", async (HttpContext ctx) => + await HandleOnvif(ctx)); + } - } + LogUtils.debug($"[WEBSERVER] Starting on port {httpPort}..."); + runTask = app.RunAsync(); + LogUtils.debug($"[WEBSERVER] Started successfully"); + } private async Task HandleOnvif(HttpContext ctx) { From 846c0a0393d4b6a17fdd6841053dffb3a24c9eb3 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 11 Jul 2026 02:09:56 +0000 Subject: [PATCH 2/3] Fix PR issues: API returns, HEVC fragmentation, SDP, debug default --- Program.cs | 2 +- src/LogUtils.cs | 2 +- src/RtspServer.cs | 67 ++++++++++++------------- src/RtspSession.cs | 42 +++++++++------- src/V380Client.cs | 121 +++++++++++++++++++++++---------------------- src/WebServer.cs | 31 ++++++------ 6 files changed, 137 insertions(+), 128 deletions(-) diff --git a/Program.cs b/Program.cs index 3b6b9ec..ab125b1 100644 --- a/Program.cs +++ b/Program.cs @@ -100,7 +100,7 @@ rtsp.Start(); webServer = new WebServer(httpPort, rtspPort, client, enableApi, enableOnvif); - webServer.Start(); + await webServer.Start(); } var cts = new CancellationTokenSource(); diff --git a/src/LogUtils.cs b/src/LogUtils.cs index b83bbdc..b7575c1 100644 --- a/src/LogUtils.cs +++ b/src/LogUtils.cs @@ -7,7 +7,7 @@ namespace V380Decoder.src { public class LogUtils { - public static bool enableDebug = true; + public static bool enableDebug = false; public static void debug(string log) { diff --git a/src/RtspServer.cs b/src/RtspServer.cs index 44e5c14..d8455d0 100644 --- a/src/RtspServer.cs +++ b/src/RtspServer.cs @@ -117,39 +117,40 @@ static int FindStartCode(byte[] d, int from) { } public string BuildSdp() { - string fmtpH264 = ""; - string fmtpH265 = ""; - string rtpmap = "a=rtpmap:96 H264/90000\r\n"; - lock (sdpLock) { - if (cachedSps != null && cachedPps != null) { - string spsB64 = Convert.ToBase64String(cachedSps); - string ppsB64 = Convert.ToBase64String(cachedPps); - string pli = cachedSps.Length >= 3 ? $"{cachedSps[0]:X2}{cachedSps[1]:X2}{cachedSps[2]:X2}" : "64001F"; - fmtpH264 = $"a=fmtp:96 packetization-mode=1;sprop-parameter-sets={spsB64},{ppsB64};profile-level-id={pli}\r\n"; - } - if (cachedVps != null && cachedHevcSps != null && cachedHevcPps != null) { - string vpsB64 = Convert.ToBase64String(cachedVps); - string spsB64 = Convert.ToBase64String(cachedHevcSps); - string ppsB64 = Convert.ToBase64String(cachedHevcPps); - fmtpH265 = $"a=fmtp:97 profile-id=1;sprop-vps={vpsB64};sprop-sps={spsB64};sprop-pps={ppsB64}\r\n"; - rtpmap = "a=rtpmap:96 H264/90000\r\na=rtpmap:97 H265/90000\r\n"; - } - } - LogUtils.debug($"[RTSP] SDP h264={!string.IsNullOrEmpty(fmtpH264)} hevc={!string.IsNullOrEmpty(fmtpH265)}"); - return "v=0\r\n" + - "o=- 1 1 IN IP4 0.0.0.0\r\n" + - "s=V380 Live\r\n" + - "t=0 0\r\n" + - "a=recvonly\r\n" + - "m=video 0 RTP/AVP 96 97\r\n" + - rtpmap + - fmtpH264 + - fmtpH265 + - "a=control:trackID=0\r\n" + - "m=audio 0 RTP/AVP 8\r\n" + - "a=rtpmap:8 PCMA/8000/1\r\n" + - "a=control:trackID=1\r\n"; - } + string fmtpH264 = ""; + string fmtpH265 = ""; + string rtpmap = "a=rtpmap:96 H264/90000\r\n"; + lock (sdpLock) { + if (cachedSps != null && cachedPps != null) { + string spsB64 = Convert.ToBase64String(cachedSps); + string ppsB64 = Convert.ToBase64String(cachedPps); + string pli = cachedSps.Length >= 3 ? $"{cachedSps[0]:X2}{cachedSps[1]:X2}{cachedSps[2]:X2}" : "64001F"; + fmtpH264 = $"a=fmtp:96 packetization-mode=1;sprop-parameter-sets={spsB64},{ppsB64};profile-level-id={pli}\r\n"; + } + if (cachedVps != null && cachedHevcSps != null && cachedHevcPps != null) { + string vpsB64 = Convert.ToBase64String(cachedVps); + string spsB64 = Convert.ToBase64String(cachedHevcSps); + string ppsB64 = Convert.ToBase64String(cachedHevcPps); + fmtpH265 = $"a=fmtp:97 profile-id=1;sprop-vps={vpsB64};sprop-sps={spsB64};sprop-pps={ppsB64}\r\n"; + rtpmap = "a=rtpmap:96 H264/90000\r\na=rtpmap:97 H265/90000\r\n"; + } + } + LogUtils.debug($"[RTSP] SDP h264={!string.IsNullOrEmpty(fmtpH264)} hevc={!string.IsNullOrEmpty(fmtpH265)}"); + string videoLine = !string.IsNullOrEmpty(fmtpH265) ? "m=video 0 RTP/AVP 96 97\r\n" : "m=video 0 RTP/AVP 96\r\n"; + return "v=0\r\n" + + "o=- 1 1 IN IP4 0.0.0.0\r\n" + + "s=V380 Live\r\n" + + "t=0 0\r\n" + + "a=recvonly\r\n" + + videoLine + + rtpmap + + fmtpH264 + + fmtpH265 + + "a=control:trackID=0\r\n" + + "m=audio 0 RTP/AVP 8\r\n" + + "a=rtpmap:8 PCMA/8000/1\r\n" + + "a=control:trackID=1\r\n"; + } public byte[] GetHevcParameterSets() { lock (sdpLock) { diff --git a/src/RtspSession.cs b/src/RtspSession.cs index bd6aed4..da82427 100644 --- a/src/RtspSession.cs +++ b/src/RtspSession.cs @@ -173,25 +173,29 @@ void SendH264Fragmented(byte[] nal, uint rts, int mtu) { } void SendHevcFragmented(byte[] nal, uint rts, int mtu) { - byte nalHdr = nal[0]; - byte fuInd = (byte)((nalHdr & 0x81) | 49); - int offset = 1; - bool first = true; - while (offset < nal.Length) { - int chunk = Math.Min(mtu - 2, nal.Length - offset); - bool last = offset + chunk >= nal.Length; - byte fuHdr = (byte)((nalHdr >> 1) & 0x3F); - if (first) fuHdr |= 0x80; - if (last) fuHdr |= 0x40; - var frag = new byte[2 + chunk]; - frag[0] = fuInd; - frag[1] = fuHdr; - Array.Copy(nal, offset, frag, 2, chunk); - SendRtp(videoCh, 97, videoSeq++, rts, videoSsrc, frag, 0, frag.Length, marker: last); - offset += chunk; - first = false; - } - } + // RFC 7798: HEVC FU-A fragmentation + // FU Indicator: 1 byte (F=0, Type=49) = 0x31 + // FU Header: 1 byte (S|E|FuType) + byte nalHdr = nal[0]; + int fuType = (nalHdr >> 1) & 0x3F; + byte fuInd = 0x31; // F=0, Type=49 + int offset = 1; + bool first = true; + while (offset < nal.Length) { + int chunk = Math.Min(mtu - 2, nal.Length - offset); + bool last = offset + chunk >= nal.Length; + byte fuHdr = (byte)fuType; + if (first) fuHdr |= 0x80; // S bit + if (last) fuHdr |= 0x40; // E bit + var frag = new byte[2 + chunk]; + frag[0] = 0x31; // FU Indicator: F=0, Type=49 + frag[1] = fuHdr; + Array.Copy(nal, offset, frag, 2, chunk); + SendRtp(videoCh, 97, videoSeq++, rts, videoSsrc, frag, 0, frag.Length, marker: last); + offset += chunk; + first = false; + } + } public void PushAudio(FrameData f) { if (!playing) return; diff --git a/src/V380Client.cs b/src/V380Client.cs index 3772ac1..74250ed 100644 --- a/src/V380Client.cs +++ b/src/V380Client.cs @@ -431,7 +431,8 @@ public void ReceiveFrames(OutputMode mode, RtspServer rtsp, CancellationToken ct } finally { - frameDump?.Dispose(); + // Don't dispose frameDump here - it's reused across reconnects + // frameDump?.Dispose(); } } @@ -511,64 +512,64 @@ private bool HandleAsMediaFrame(byte rawType, byte[] full, bool needDecrypt, Out return true; } - if (rawType == 0x18 || rawType == 0x28 || rawType == 0x29) - { - const int headerSize = 16; - if (full.Length <= headerSize) return false; - byte[] payload = new byte[full.Length - headerSize]; - Array.Copy(full, headerSize, payload, 0, payload.Length); - - // dump raw frame before decryption - if (frameDumpEnabled) - { - frameDump?.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] V rawType=0x{rawType:X2} len={full.Length} raw_head={BitConverter.ToString(full, 0, Math.Min(32, full.Length))}"); - frameDump?.Flush(); - var binPath = "/home/favour/camera/frame_dump.bin"; - using var bfs = new FileStream(binPath, FileMode.Append, FileAccess.Write, FileShare.Read); - bfs.Write(full, 0, full.Length); - } - - if (needDecrypt && payload.Length >= 16) - { - DecryptVideoFrame(payload, payload.Length); - } - - // dump payload after decryption - if (frameDumpEnabled) - { - frameDump?.WriteLine($" -> decrypted len={payload.Length} head={BitConverter.ToString(payload, 0, Math.Min(64, payload.Length))}"); - frameDump?.Flush(); - var binPath = "/home/favour/camera/frame_dump.bin"; - using var bfs = new FileStream(binPath, FileMode.Append, FileAccess.Write, FileShare.Read); - bfs.Write(payload, 0, payload.Length); - } - - bool keyFrame = IsKeyVideoFrame(rawType, outerFrameType, payload); - if (mode == OutputMode.Rtsp) - { - snapshotManager.UpdateFrame(payload, frameWidth, frameheight, keyFrame, outerTimestamp); - } - var vFrame = new FrameData - { - RawType = rawType, - FrameId = outerFrameId, - FrameType = outerFrameType, - FrameRate = outerFrameRate, - Timestamp = outerTimestamp, - Codec = VideoCodec.H265, - Payload = payload - }; - if (mode == OutputMode.Video) - { - stdout?.Write(payload, 0, payload.Length); - stdout?.Flush(); - } - else if (mode == OutputMode.Rtsp) - { - rtsp?.PushVideo(vFrame); - } - return true; - } + if (rawType == 0x18 || rawType == 0x28 || rawType == 0x29 || rawType == 0x5C) + { + const int headerSize = 16; + if (full.Length <= headerSize) return false; + byte[] payload = new byte[full.Length - headerSize]; + Array.Copy(full, headerSize, payload, 0, payload.Length); + + // dump raw frame before decryption + if (frameDumpEnabled) + { + frameDump?.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] V rawType=0x{rawType:X2} len={full.Length} raw_head={BitConverter.ToString(full, 0, Math.Min(32, full.Length))}"); + frameDump?.Flush(); + var binPath = "/home/favour/camera/frame_dump.bin"; + using var bfs = new FileStream(binPath, FileMode.Append, FileAccess.Write, FileShare.Read); + bfs.Write(full, 0, full.Length); + } + + if (needDecrypt && payload.Length >= 16) + { + DecryptVideoFrame(payload, payload.Length); + } + + // dump payload after decryption + if (frameDumpEnabled) + { + frameDump?.WriteLine($" -> decrypted len={payload.Length} head={BitConverter.ToString(payload, 0, Math.Min(64, payload.Length))}"); + frameDump?.Flush(); + var binPath = "/home/favour/camera/frame_dump.bin"; + using var bfs = new FileStream(binPath, FileMode.Append, FileAccess.Write, FileShare.Read); + bfs.Write(payload, 0, payload.Length); + } + + bool keyFrame = IsKeyVideoFrame(rawType, outerFrameType, payload); + if (mode == OutputMode.Rtsp) + { + snapshotManager.UpdateFrame(payload, frameWidth, frameheight, keyFrame, outerTimestamp); + } + var vFrame = new FrameData + { + RawType = rawType, + FrameId = outerFrameId, + FrameType = outerFrameType, + FrameRate = outerFrameRate, + Timestamp = outerTimestamp, + Codec = VideoCodec.H265, + Payload = payload + }; + if (mode == OutputMode.Video) + { + stdout?.Write(payload, 0, payload.Length); + stdout?.Flush(); + } + else if (mode == OutputMode.Rtsp) + { + rtsp?.PushVideo(vFrame); + } + return true; + } if (!TryExtractVideoPayload(full, needDecrypt, out var normalizedPayload)) { LogUtils.debug($"[FRAME] unclassified type=0x{rawType:X2} len={full.Length} head={BitConverter.ToString(full, 0, Math.Min(16, full.Length))}"); @@ -706,7 +707,7 @@ private bool TryNormalizeVideoPayload(byte[] payload, out byte[] normalizedPaylo private bool IsKeyVideoFrame(byte rawType, ushort frameType, byte[] payload) { - if (rawType == 0x00) return true; + if (rawType == 0x00 || rawType == 0x5C) return true; int nalOffset = payload.Length >= 4 && payload[2] == 0 && payload[3] == 1 ? 4 : 3; if (payload.Length <= nalOffset) return false; diff --git a/src/WebServer.cs b/src/WebServer.cs index 800b607..5e8ff34 100644 --- a/src/WebServer.cs +++ b/src/WebServer.cs @@ -26,7 +26,7 @@ public WebServer(int httpPort, int rtspPort, V380Client client, bool enableApi, this.enableOnvif = enableOnvif; } - public void Start() + public async Task Start() { string ipAddress = NetworkHelper.GetLocalIPAddress(); var builder = WebApplication.CreateBuilder(); @@ -101,20 +101,20 @@ public void Start() app.MapGet("/", () => Results.Content(WebPage.GetHtml(), "text/html")); - app.MapPost("/api/ptz/right", () => { client.PtzRight(); LogUtils.debug("[API] PTZ Right"); Results.Ok(); }); - app.MapPost("/api/ptz/left", () => { client.PtzLeft(); LogUtils.debug("[API] PTZ Left"); Results.Ok(); }); - app.MapPost("/api/ptz/up", () => { client.PtzUp(); LogUtils.debug("[API] PTZ Up"); Results.Ok(); }); - app.MapPost("/api/ptz/down", () => { client.PtzDown(); LogUtils.debug("[API] PTZ Down"); Results.Ok(); }); - app.MapPost("/api/ptz/stop", () => { client.PtzStop(); LogUtils.debug("[API] PTZ Stop"); Results.Ok(); }); + app.MapPost("/api/ptz/right", () => { client.PtzRight(); LogUtils.debug("[API] PTZ Right"); return Results.Ok(); }); + app.MapPost("/api/ptz/left", () => { client.PtzLeft(); LogUtils.debug("[API] PTZ Left"); return Results.Ok(); }); + app.MapPost("/api/ptz/up", () => { client.PtzUp(); LogUtils.debug("[API] PTZ Up"); return Results.Ok(); }); + app.MapPost("/api/ptz/down", () => { client.PtzDown(); LogUtils.debug("[API] PTZ Down"); return Results.Ok(); }); + app.MapPost("/api/ptz/stop", () => { client.PtzStop(); LogUtils.debug("[API] PTZ Stop"); return Results.Ok(); }); - app.MapPost("/api/light/on", () => { client.LightOn(); LogUtils.debug("[API] Light On"); Results.Ok(); }); - app.MapPost("/api/light/off", () => { client.LightOff(); LogUtils.debug("[API] Light Off"); Results.Ok(); }); - app.MapPost("/api/light/auto", () => { client.LightAuto(); LogUtils.debug("[API] Light Auto"); Results.Ok(); }); + app.MapPost("/api/light/on", () => { client.LightOn(); LogUtils.debug("[API] Light On"); return Results.Ok(); }); + app.MapPost("/api/light/off", () => { client.LightOff(); LogUtils.debug("[API] Light Off"); return Results.Ok(); }); + app.MapPost("/api/light/auto", () => { client.LightAuto(); LogUtils.debug("[API] Light Auto"); return Results.Ok(); }); - app.MapPost("/api/image/color", () => { client.ImageColor(); LogUtils.debug("[API] Image Color"); Results.Ok(); }); - app.MapPost("/api/image/bw", () => { client.ImageBW(); LogUtils.debug("[API] Image B&W"); Results.Ok(); }); - app.MapPost("/api/image/auto", () => { client.ImageAuto(); LogUtils.debug("[API] Image Auto"); Results.Ok(); }); - app.MapPost("/api/image/flip", () => { client.ImageFlip(); LogUtils.debug("[API] Image Flip"); Results.Ok(); }); + app.MapPost("/api/image/color", () => { client.ImageColor(); LogUtils.debug("[API] Image Color"); return Results.Ok(); }); + app.MapPost("/api/image/bw", () => { client.ImageBW(); LogUtils.debug("[API] Image B&W"); return Results.Ok(); }); + app.MapPost("/api/image/auto", () => { client.ImageAuto(); LogUtils.debug("[API] Image Auto"); return Results.Ok(); }); + app.MapPost("/api/image/flip", () => { client.ImageFlip(); LogUtils.debug("[API] Image Flip"); return Results.Ok(); }); app.MapGet("/api/status", () => Results.Ok(new StatusResponse { @@ -142,7 +142,10 @@ public void Start() LogUtils.debug($"[WEBSERVER] Starting on port {httpPort}..."); runTask = app.RunAsync(); - LogUtils.debug($"[WEBSERVER] Started successfully"); + LogUtils.debug($"[WEBSERVER] Started successfully - waiting for task to complete"); + // Give Kestrel a moment to bind + await Task.Delay(100); + LogUtils.debug($"[WEBSERVER] After delay, task status: {runTask.Status}"); } private async Task HandleOnvif(HttpContext ctx) From 950dde10a87a3ea56f2a9b3d9f942512741dc2b5 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 11 Jul 2026 16:54:50 +0000 Subject: [PATCH 3/3] fix: accept auth result 1002 in addition to 1001 --- src/V380Client.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/V380Client.cs b/src/V380Client.cs index 74250ed..8d4cbfd 100644 --- a/src/V380Client.cs +++ b/src/V380Client.cs @@ -155,7 +155,7 @@ public int GetAuthTicket() return 0; } uint loginResult = ReadUInt32LE(resp, 4); - if (loginResult != 1001) + if (loginResult != 1001 && loginResult != 1002) { if (loginResult == 1011) Console.Error.WriteLine($"[AUTH] invalid username. exiting...");