From 20ac1258de3de516b566ad4e065e65f243530a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8=D1=87=20?= =?UTF-8?q?=D0=98=D0=B2=D0=B0=D0=BD=D0=BE=D0=B2?= <32629144+AspirantDrago@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:49:40 +0500 Subject: [PATCH 1/2] Fix encrypted HEVC cloud streams --- .github/workflows/build.yml | 16 + Dockerfile | 5 +- README.md | 27 +- V380Decoder.csproj | 5 +- src/AnnexB.cs | 149 +++++++ src/AssemblyInfo.cs | 3 + src/Enum.cs | 3 +- src/FrameData.cs | 7 +- src/HevcSpsParser.cs | 191 ++++++++ src/RtspServer.cs | 133 +++--- src/RtspSession.cs | 132 ++++-- src/SnapshotManager.cs | 284 +++++++++--- src/V380Client.cs | 412 ++++++++++++------ src/VideoPayloadDecoder.cs | 190 ++++++++ .../V380Decoder.Tests.csproj | 19 + .../VideoPayloadDecoderTests.cs | 136 ++++++ 16 files changed, 1398 insertions(+), 314 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 src/AnnexB.cs create mode 100644 src/AssemblyInfo.cs create mode 100644 src/HevcSpsParser.cs create mode 100644 src/VideoPayloadDecoder.cs create mode 100644 tests/V380Decoder.Tests/V380Decoder.Tests.csproj create mode 100644 tests/V380Decoder.Tests/VideoPayloadDecoderTests.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..dba178a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,16 @@ +name: Build + +on: + push: + branches: [main, "agent/**"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - run: dotnet test tests/V380Decoder.Tests/V380Decoder.Tests.csproj --configuration Release diff --git a/Dockerfile b/Dockerfile index 0516522..9e9c1f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,9 @@ RUN dotnet publish V380Decoder.csproj -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* COPY --from=build /app . EXPOSE 8554/tcp 8080/tcp -ENTRYPOINT ["./V380Decoder"] \ No newline at end of file +ENTRYPOINT ["./V380Decoder"] diff --git a/README.md b/README.md index f4546f2..f854582 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,14 @@ This is a port of [prsyahmi/v380](https://github.com/prsyahmi/v380) with signifi - ✅ Web UI and REST API for camera control - ✅ Snapshot API - ✅ Cloud relay streaming support +- ✅ H.265/HEVC streams (`0x28` keyframes and `0x29` inter frames) +- ✅ Automatic media decryption layout detection for protocol versions 20/21 ## Tested Cameras -**Note:** I've only tested with 2 V380 cameras running device version 31 with H264 stream. +H.264 device-version-31 cameras and encrypted H.265 device-version-32 +cloud streams are supported. V380 is a protocol family rather than one fixed +camera format, so logs from additional models are welcome. **Camera 1:** - Software: `AppEV2W_VA3_V2.5.9.5_20231211` @@ -25,7 +29,7 @@ This is a port of [prsyahmi/v380](https://github.com/prsyahmi/v380) with signifi ## Requirements - .NET 10 SDK (for building from source) -- FFmpeg (optional, for snapshot or piping video/audio output) +- FFmpeg (required for H.265 snapshots/MJPEG; optional for H.264-only use) ## Command Line Arguments @@ -58,7 +62,11 @@ Download Latest [Release](https://github.com/PyanSofyan/V380decoder/releases/lat ``` ### Video Output (pipe to FFplay) ```bash +# H.264 camera ./V380Decoder --id 12345678 --username admin --password password --ip 192.168.1.2 --output video | ffplay -f h264 -i pipe:0 + +# H.265 camera +./V380Decoder --id 12345678 --username admin --password password --source cloud --output video | ffplay -f hevc -i pipe:0 ``` ### Audio Output (pipe to FFplay) @@ -91,6 +99,18 @@ Streaming via relay server (relay IP automatically detected): ./V380Decoder --id 12345678 --username admin --password password --source cloud ``` +For newer encrypted cameras, startup logs should report the detected codec and +decryption layout, for example: + +```text +[VIDEO] codec=H265 decrypt=FullBlocks header=16 +[RTSP] video codec=H265 +``` + +If the media protocol version is absent from a relay response, the decoder +validates Annex-B NAL units and automatically chooses between the version-20 +selective `64/80` AES layout and version-21 full-block AES. + ## ONVIF Support Tested with [Onvif Device Manager](https://sourceforge.net/projects/onvifdm/) (ODM), [Onvif Integration](https://www.home-assistant.io/integrations/onvif/) Home Assistant and [Shinobi](https://shinobi.video/) @@ -243,6 +263,9 @@ docker build -t v380decoder . docker run -d --restart unless-stopped --network host v380decoder --id 12345678 --username admin --password password --ip 192.168.1.2 --enable-onvif --enable-api ``` +The Docker image includes FFmpeg, so H.265 snapshots and MJPEG work without an +additional package installation. + ## Acknowledgements - [prsyahmi/v380](https://github.com/prsyahmi/v380) - Original V380 reverse engineering work diff --git a/V380Decoder.csproj b/V380Decoder.csproj index 7765e3d..d9ab36e 100644 --- a/V380Decoder.csproj +++ b/V380Decoder.csproj @@ -13,4 +13,7 @@ - \ No newline at end of file + + + + diff --git a/src/AnnexB.cs b/src/AnnexB.cs new file mode 100644 index 0000000..7772459 --- /dev/null +++ b/src/AnnexB.cs @@ -0,0 +1,149 @@ +namespace V380Decoder.src +{ + internal static class AnnexB + { + public static bool TryNormalize(byte[] data, out byte[] normalized) + { + int start = FindStartCode(data, 0); + if (start < 0 || start > 128) + { + normalized = Array.Empty(); + return false; + } + + int startCodeLength = GetStartCodeLength(data, start); + if (startCodeLength == 4 && start == 0) + { + normalized = data; + return true; + } + + int payloadLength = data.Length - start - startCodeLength; + if (payloadLength <= 0) + { + normalized = Array.Empty(); + return false; + } + + normalized = new byte[4 + payloadLength]; + normalized[3] = 1; + Array.Copy(data, start + startCodeLength, normalized, 4, payloadLength); + return true; + } + + public static void Parse(byte[] data, VideoCodec codec, Action callback) + { + int offset = 0; + while (offset < data.Length) + { + int start = FindStartCode(data, offset); + if (start < 0) break; + + int nalStart = start + GetStartCodeLength(data, start); + if (nalStart >= data.Length) break; + + int next = FindStartCode(data, nalStart); + int nalEnd = next < 0 ? data.Length : next; + int nalLength = nalEnd - nalStart; + if (nalLength <= 0) + { + offset = nalStart; + continue; + } + + if (codec == VideoCodec.H265 && nalLength < 2) + { + offset = nalEnd; + continue; + } + + int nalType = codec == VideoCodec.H265 + ? (data[nalStart] >> 1) & 0x3F + : data[nalStart] & 0x1F; + + var nal = new byte[nalLength]; + Array.Copy(data, nalStart, nal, 0, nalLength); + callback(nalType, nal); + offset = nalEnd; + } + } + + public static int Score(byte[] data, VideoCodec codec) + { + int score = 0; + int count = 0; + bool invalid = false; + + Parse(data, codec, (type, nal) => + { + count++; + + if (codec == VideoCodec.H264) + { + if (type is <= 0 or >= 24) + { + invalid = true; + return; + } + + score += type switch + { + 7 => 60, + 8 => 50, + 5 => 40, + _ => 8 + }; + return; + } + + bool validHeader = nal.Length >= 2 && + (nal[0] & 0x80) == 0 && + (nal[1] & 0x07) != 0; + if (!validHeader) + { + invalid = true; + return; + } + + score += type switch + { + 32 => 60, // VPS + 33 => HevcSpsParser.TryGetDimensions(nal, out _, out _) + ? 270 + : MarkInvalid(), + 34 => 60, // PPS + >= 16 and <= 23 => 45, // IRAP/IDR/CRA + <= 31 => 10, // VCL + <= 47 => 5, // common non-VCL + _ => 1 + }; + + int MarkInvalid() + { + invalid = true; + return 0; + } + }); + + if (count == 0 || invalid) return int.MinValue; + return score + Math.Min(count, 12) * 2; + } + + public static int FindStartCode(byte[] data, int from) + { + for (int i = Math.Max(from, 0); i + 2 < data.Length; i++) + { + if (data[i] != 0 || data[i + 1] != 0) continue; + if (data[i + 2] == 1) return i; + if (i + 3 < data.Length && data[i + 2] == 0 && data[i + 3] == 1) + return i; + } + return -1; + } + + private static int GetStartCodeLength(byte[] data, int offset) + { + return offset + 2 < data.Length && data[offset + 2] == 1 ? 3 : 4; + } + } +} diff --git a/src/AssemblyInfo.cs b/src/AssemblyInfo.cs new file mode 100644 index 0000000..546bb6a --- /dev/null +++ b/src/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("V380Decoder.Tests")] diff --git a/src/Enum.cs b/src/Enum.cs index 59e3b73..b9836a1 100644 --- a/src/Enum.cs +++ b/src/Enum.cs @@ -2,4 +2,5 @@ namespace V380Decoder.src { public enum OutputMode { Video, Audio, Rtsp } public enum SourceStream { Lan, Cloud } -} \ No newline at end of file + public enum VideoCodec { H264, H265 } +} diff --git a/src/FrameData.cs b/src/FrameData.cs index 61a74ad..402fc49 100644 --- a/src/FrameData.cs +++ b/src/FrameData.cs @@ -2,12 +2,13 @@ namespace V380Decoder.src { public class FrameData { - public byte RawType; // fragment header type byte (0x00/0x01/0x1A) + public byte RawType; public uint FrameId; public ushort FrameType; public ushort FrameRate; public ulong Timestamp; + public VideoCodec Codec = VideoCodec.H264; public byte[] Payload; - public bool IsKeyframe => RawType == 0x00; + public bool IsKeyframe => RawType is 0x00 or 0x28; } -} \ No newline at end of file +} diff --git a/src/HevcSpsParser.cs b/src/HevcSpsParser.cs new file mode 100644 index 0000000..bf2bdee --- /dev/null +++ b/src/HevcSpsParser.cs @@ -0,0 +1,191 @@ +namespace V380Decoder.src +{ + internal static class HevcSpsParser + { + public static bool TryGetDimensions(byte[] nal, out int width, out int height) + { + width = 0; + height = 0; + if (nal == null || nal.Length < 4 || ((nal[0] >> 1) & 0x3F) != 33) + return false; + + byte[] rbsp = RemoveEmulationPreventionBytes(nal, 2); + var bits = new BitReader(rbsp); + + if (!bits.TryReadBits(4, out _) || + !bits.TryReadBits(3, out uint maxSubLayersMinus1) || + maxSubLayersMinus1 > 6 || + !bits.TryReadBits(1, out _) || + !TrySkipProfileTierLevel(bits, (int)maxSubLayersMinus1) || + !bits.TryReadUnsignedExpGolomb(out uint spsId) || + spsId > 15 || + !bits.TryReadUnsignedExpGolomb(out uint chromaFormatIdc) || + chromaFormatIdc > 3) + return false; + + bool separateColourPlane = false; + if (chromaFormatIdc == 3) + { + if (!bits.TryReadBits(1, out uint separate)) return false; + separateColourPlane = separate != 0; + } + + if (!bits.TryReadUnsignedExpGolomb(out uint codedWidth) || + !bits.TryReadUnsignedExpGolomb(out uint codedHeight) || + codedWidth == 0 || + codedHeight == 0 || + codedWidth > 32768 || + codedHeight > 32768 || + !bits.TryReadBits(1, out uint conformanceWindowFlag)) + return false; + + uint cropLeft = 0; + uint cropRight = 0; + uint cropTop = 0; + uint cropBottom = 0; + if (conformanceWindowFlag != 0 && + (!bits.TryReadUnsignedExpGolomb(out cropLeft) || + !bits.TryReadUnsignedExpGolomb(out cropRight) || + !bits.TryReadUnsignedExpGolomb(out cropTop) || + !bits.TryReadUnsignedExpGolomb(out cropBottom))) + return false; + + uint chromaArrayType = separateColourPlane ? 0 : chromaFormatIdc; + uint subWidth = chromaArrayType is 1 or 2 ? 2u : 1u; + uint subHeight = chromaArrayType == 1 ? 2u : 1u; + ulong horizontalCrop = (ulong)subWidth * (cropLeft + cropRight); + ulong verticalCrop = (ulong)subHeight * (cropTop + cropBottom); + if (horizontalCrop >= codedWidth || verticalCrop >= codedHeight) + return false; + + uint displayWidth = codedWidth - (uint)horizontalCrop; + uint displayHeight = codedHeight - (uint)verticalCrop; + + if (!bits.TryReadUnsignedExpGolomb(out uint bitDepthLumaMinus8) || + !bits.TryReadUnsignedExpGolomb(out uint bitDepthChromaMinus8) || + !bits.TryReadUnsignedExpGolomb(out uint log2MaxPocLsbMinus4) || + bitDepthLumaMinus8 > 8 || + bitDepthChromaMinus8 > 8 || + log2MaxPocLsbMinus4 > 12 || + displayWidth < 16 || + displayHeight < 16) + return false; + + width = (int)displayWidth; + height = (int)displayHeight; + return true; + } + + private static bool TrySkipProfileTierLevel(BitReader bits, int maxSubLayersMinus1) + { + if (!bits.TrySkipBits(88) || !bits.TryReadBits(8, out uint levelIdc) || levelIdc == 0) + return false; + + var profilePresent = new bool[maxSubLayersMinus1]; + var levelPresent = new bool[maxSubLayersMinus1]; + for (int i = 0; i < maxSubLayersMinus1; i++) + { + if (!bits.TryReadBits(1, out uint profile) || + !bits.TryReadBits(1, out uint level)) + return false; + profilePresent[i] = profile != 0; + levelPresent[i] = level != 0; + } + + if (maxSubLayersMinus1 > 0) + { + for (int i = maxSubLayersMinus1; i < 8; i++) + { + if (!bits.TryReadBits(2, out uint reserved) || reserved != 0) + return false; + } + } + + for (int i = 0; i < maxSubLayersMinus1; i++) + { + if (profilePresent[i] && !bits.TrySkipBits(88)) + return false; + if (levelPresent[i] && !bits.TrySkipBits(8)) + return false; + } + + return true; + } + + private static byte[] RemoveEmulationPreventionBytes(byte[] data, int offset) + { + var rbsp = new List(data.Length - offset); + int zeroCount = 0; + for (int i = offset; i < data.Length; i++) + { + byte value = data[i]; + if (zeroCount >= 2 && + value == 0x03 && + i + 1 < data.Length && + data[i + 1] <= 0x03) + { + zeroCount = 0; + continue; + } + + rbsp.Add(value); + zeroCount = value == 0 ? zeroCount + 1 : 0; + } + return rbsp.ToArray(); + } + + private sealed class BitReader + { + private readonly byte[] data; + private int bitOffset; + + public BitReader(byte[] data) + { + this.data = data; + } + + public bool TryReadBits(int count, out uint value) + { + value = 0; + if (count is < 0 or > 32 || bitOffset + count > data.Length * 8) + return false; + + for (int i = 0; i < count; i++) + { + int byteOffset = bitOffset >> 3; + int shift = 7 - (bitOffset & 7); + value = (value << 1) | (uint)((data[byteOffset] >> shift) & 1); + bitOffset++; + } + return true; + } + + public bool TrySkipBits(int count) + { + if (count < 0 || bitOffset + count > data.Length * 8) + return false; + bitOffset += count; + return true; + } + + public bool TryReadUnsignedExpGolomb(out uint value) + { + value = 0; + int leadingZeros = 0; + while (true) + { + if (!TryReadBits(1, out uint bit)) return false; + if (bit != 0) break; + leadingZeros++; + if (leadingZeros > 31) return false; + } + + if (leadingZeros == 0) return true; + if (!TryReadBits(leadingZeros, out uint suffix)) return false; + + value = ((1u << leadingZeros) - 1u) + suffix; + return true; + } + } + } +} diff --git a/src/RtspServer.cs b/src/RtspServer.cs index 59679e0..54b7fa8 100644 --- a/src/RtspServer.cs +++ b/src/RtspServer.cs @@ -18,8 +18,10 @@ public class RtspServer private readonly ConcurrentDictionary sessions = new(); private int nextId; - // SPS/PPS from first keyframe – used for SDP fmtp line - private byte[] cachedSps, cachedPps; + // Codec parameter sets used in SDP. + private byte[] cachedVps, cachedSps, cachedPps; + private VideoCodec cachedCodec = VideoCodec.H264; + private bool codecKnown; private readonly object sdpLock = new(); public RtspServer(int port, bool secure, string username, string password) @@ -66,7 +68,7 @@ void AcceptLoop() // Called from main receive loop for every complete video frame public void PushVideo(FrameData f) { - if (f.IsKeyframe) CacheSpsFromIdr(f.Payload); + CacheParameterSets(f); foreach (var s in sessions.Values) s.PushVideo(f); } @@ -76,81 +78,104 @@ public void PushAudio(FrameData f) foreach (var s in sessions.Values) s.PushAudio(f); } - // ── SPS/PPS extraction ─────────────────────────────────── - void CacheSpsFromIdr(byte[] data) + // ── codec parameter set extraction ────────────────────── + void CacheParameterSets(FrameData frame) { lock (sdpLock) { - if (cachedSps != null && cachedPps != null) return; // already cached - ParseNals(data, (nalType, nal) => + if (!codecKnown || cachedCodec != frame.Codec) { - if (nalType == 7 && cachedSps == null) cachedSps = nal; - if (nalType == 8 && cachedPps == null) cachedPps = nal; + cachedCodec = frame.Codec; + codecKnown = true; + cachedVps = null; + cachedSps = null; + cachedPps = null; + Console.Error.WriteLine($"[RTSP] video codec={cachedCodec}"); + } + + ParseNals(frame.Payload, frame.Codec, (nalType, nal) => + { + if (frame.Codec == VideoCodec.H264) + { + if (nalType == 7) cachedSps = nal; + if (nalType == 8) cachedPps = nal; + return; + } + + if (nalType == 32) cachedVps = nal; + if (nalType == 33) cachedSps = nal; + if (nalType == 34) cachedPps = nal; }); } } - // Walk H.264 Annex-B start codes, call cb(nalType, nalBytes) for each NAL - internal static void ParseNals(byte[] data, Action cb) + // Walk Annex-B start codes, call cb(nalType, nalBytes) for each NAL. + internal static void ParseNals( + byte[] data, + VideoCodec codec, + Action callback) { - int i = 0, len = data.Length; - while (i < len) - { - // find start code - 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; - // find next start code - int next = FindStartCode(data, nalStart); - int nalEnd = next < 0 ? len : next; - int nalType = data[nalStart] & 0x1F; - var nal = new byte[nalEnd - nalStart]; - Array.Copy(data, nalStart, nal, 0, nal.Length); - cb(nalType, nal); - i = nalEnd; - } + AnnexB.Parse(data, codec, callback); } - static int FindStartCode(byte[] d, int from) + public string BuildSdp() { - for (int i = from; i + 3 < d.Length; i++) + string videoDescription; + lock (sdpLock) { - if (d[i] == 0 && d[i + 1] == 0) + if (!codecKnown) { - if (d[i + 2] == 1) return i; - if (d[i + 2] == 0 && i + 3 < d.Length && d[i + 3] == 1) return i; + videoDescription = + "m=video 0 RTP/AVP 96 97\r\n" + + "a=rtpmap:96 H264/90000\r\n" + + "a=rtpmap:97 H265/90000\r\n"; } - } - return -1; - } + else if (cachedCodec == VideoCodec.H265) + { + string fmtp = ""; + if (cachedVps != null && cachedSps != null && cachedPps != null) + { + fmtp = + "a=fmtp:97 " + + $"sprop-vps={Convert.ToBase64String(cachedVps)};" + + $"sprop-sps={Convert.ToBase64String(cachedSps)};" + + $"sprop-pps={Convert.ToBase64String(cachedPps)}\r\n"; + } - public string BuildSdp() - { - string fmtp = ""; - lock (sdpLock) - { - if (cachedSps != null && cachedPps != null) + videoDescription = + "m=video 0 RTP/AVP 97\r\n" + + "a=rtpmap:97 H265/90000\r\n" + + fmtp; + } + else { - string spsB64 = Convert.ToBase64String(cachedSps); - string ppsB64 = Convert.ToBase64String(cachedPps); - // profile-level-id = first 3 bytes of SPS (after NAL header) - 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"; + string fmtp = ""; + if (cachedSps != null && cachedPps != null) + { + string profileLevelId = cachedSps.Length >= 4 + ? $"{cachedSps[1]:X2}{cachedSps[2]:X2}{cachedSps[3]:X2}" + : "64001F"; + fmtp = + "a=fmtp:96 packetization-mode=1;" + + $"sprop-parameter-sets={Convert.ToBase64String(cachedSps)}," + + $"{Convert.ToBase64String(cachedPps)};" + + $"profile-level-id={profileLevelId}\r\n"; + } + + videoDescription = + "m=video 0 RTP/AVP 96\r\n" + + "a=rtpmap:96 H264/90000\r\n" + + fmtp; } } + 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 + + videoDescription + "a=control:trackID=0\r\n" + "m=audio 0 RTP/AVP 8\r\n" + "a=rtpmap:8 PCMA/8000/1\r\n" + @@ -164,4 +189,4 @@ public void Dispose() foreach (var s in sessions.Values) s.Close(); } } -} \ No newline at end of file +} diff --git a/src/RtspSession.cs b/src/RtspSession.cs index 8a80b6b..029be63 100644 --- a/src/RtspSession.cs +++ b/src/RtspSession.cs @@ -151,6 +151,8 @@ void HandleRequest(string req) 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}", @@ -196,51 +198,109 @@ void Send(string s) catch { alive = false; } } - // ── RTP video push (H.264 Annex-B → RTP NAL/FU-A) ────── + // ── RTP video push (Annex-B H.264/H.265 → RTP) ────────── public void PushVideo(FrameData f) { if (!playing) return; // RTP timestamp: 90000 Hz, camera timestamp in milliseconds uint rts = (uint)(f.Timestamp * 90); + var nals = new List(); + RtspServer.ParseNals(f.Payload, f.Codec, (_, nal) => nals.Add(nal)); - RtspServer.ParseNals(f.Payload, (nalType, nal) => + for (int index = 0; index < nals.Count; index++) { - const int MTU = 1400; - if (nal.Length <= MTU) - { - // Single NAL unit packet - SendRtp(videoCh, 96, videoSeq++, rts, videoSsrc, nal, 0, nal.Length, marker: true); - } + bool lastNal = index == nals.Count - 1; + if (f.Codec == VideoCodec.H265) + PushH265Nal(nals[index], rts, lastNal); else - { - // FU-A fragmentation - byte nalHdr = nal[0]; - byte fuInd = (byte)((nalHdr & 0xE0) | 28); // NRI from original, type=28 - int offset = 1; // skip original NAL header - bool first = true; + PushH264Nal(nals[index], rts, lastNal); + } + } - while (offset < nal.Length) - { - int chunk = Math.Min(MTU - 2, nal.Length - offset); - bool last = offset + chunk >= nal.Length; - - byte fuHdr = (byte)(nalHdr & 0x1F); // NAL type - if (first) fuHdr |= 0x80; // S bit - if (last) fuHdr |= 0x40; // E bit - - 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; - } - } - }); + private void PushH264Nal(byte[] nal, uint timestamp, bool lastNal) + { + const int MaxPayload = 1400; + if (nal.Length <= MaxPayload) + { + SendRtp( + videoCh, 96, videoSeq++, timestamp, videoSsrc, + nal, 0, nal.Length, marker: lastNal); + return; + } + + byte nalHeader = nal[0]; + byte fuIndicator = (byte)((nalHeader & 0xE0) | 28); + int offset = 1; + bool first = true; + + while (offset < nal.Length) + { + int chunk = Math.Min(MaxPayload - 2, nal.Length - offset); + bool lastFragment = offset + chunk >= nal.Length; + byte fuHeader = (byte)(nalHeader & 0x1F); + if (first) fuHeader |= 0x80; + if (lastFragment) fuHeader |= 0x40; + + var fragment = new byte[2 + chunk]; + fragment[0] = fuIndicator; + fragment[1] = fuHeader; + Array.Copy(nal, offset, fragment, 2, chunk); + + SendRtp( + videoCh, 96, videoSeq++, timestamp, videoSsrc, + fragment, 0, fragment.Length, + marker: lastNal && lastFragment); + + offset += chunk; + first = false; + } + } + + private void PushH265Nal(byte[] nal, uint timestamp, bool lastNal) + { + const int MaxPayload = 1400; + if (nal.Length < 2) return; + + if (nal.Length <= MaxPayload) + { + SendRtp( + videoCh, 97, videoSeq++, timestamp, videoSsrc, + nal, 0, nal.Length, marker: lastNal); + return; + } + + int nalType = (nal[0] >> 1) & 0x3F; + + // RFC 7798 section 4.4.3: + // two-byte PayloadHdr (type 49) + one-byte FU header. + byte payloadHeader0 = (byte)((nal[0] & 0x81) | (49 << 1)); + byte payloadHeader1 = nal[1]; + int offset = 2; + bool first = true; + + while (offset < nal.Length) + { + int chunk = Math.Min(MaxPayload - 3, nal.Length - offset); + bool lastFragment = offset + chunk >= nal.Length; + byte fuHeader = (byte)nalType; + if (first) fuHeader |= 0x80; + if (lastFragment) fuHeader |= 0x40; + + var fragment = new byte[3 + chunk]; + fragment[0] = payloadHeader0; + fragment[1] = payloadHeader1; + fragment[2] = fuHeader; + Array.Copy(nal, offset, fragment, 3, chunk); + + SendRtp( + videoCh, 97, videoSeq++, timestamp, videoSsrc, + fragment, 0, fragment.Length, + marker: lastNal && lastFragment); + + offset += chunk; + first = false; + } } // ── RTP audio push (PCMA raw samples) ────────────────── @@ -289,4 +349,4 @@ void SendRtp(byte channel, byte pt, ushort seq, uint ts, uint ssrc, catch { alive = false; } } } -} \ No newline at end of file +} diff --git a/src/SnapshotManager.cs b/src/SnapshotManager.cs index 5bf5aca..d5542d0 100644 --- a/src/SnapshotManager.cs +++ b/src/SnapshotManager.cs @@ -12,6 +12,8 @@ public class SnapshotManager : IDisposable private readonly object _lock = new(); private byte[] _cachedJpeg = null; private int _width, _height; + private VideoCodec _codec = VideoCodec.H264; + private bool _codecKnown; // ── mjpeg subscribers ───────────────────────────────────── private readonly List> _subscribers = new(); @@ -29,6 +31,8 @@ public class SnapshotManager : IDisposable // FFmpeg path private Process _ffmpegProc; private Stream _ffmpegStdin; + private VideoCodec? _ffmpegCodec; + private bool _ffmpegAwaitingKeyframe = true; // Frame queue private readonly System.Threading.Channels.Channel<(byte[] data, bool isIFrame)> _queue = @@ -39,21 +43,20 @@ public class SnapshotManager : IDisposable SingleReader = true }); - // SPS/PPS for prepend to I-frame (H264Sharp path) - private byte[] _sps, _pps; + // H.264 SPS/PPS or H.265 VPS/SPS/PPS + private byte[] _vps, _sps, _pps; private bool _mjpegActive = false; private byte[] _lastIFrame = null; private readonly SemaphoreSlim _snapshotSem = new(1, 1); + private bool _reportedMissingHevcDecoder; public SnapshotManager() { _useFFmpeg = IsFFmpegAvailable(); LogUtils.debug($"[SNAP] decoder={(_useFFmpeg ? "FFmpeg" : "H264Sharp")}"); - if (_useFFmpeg) - StartFFmpegPipe(); - else + if (!_useFFmpeg) _decoder = new H264Decoder(); Task.Run(() => DecodeLoop(_cts.Token)); @@ -65,20 +68,44 @@ public void SetMjpegActive(bool active) { _mjpegActive = active; LogUtils.debug($"[SNAP] MJPEG {(active ? "enable" : "disabled")}"); + + if (active && _useFFmpeg && _codecKnown) + EnsureFFmpegPipe(_codec); } - public void UpdateFrame(byte[] h264Frame, int width, int height, bool isIFrame) + public void UpdateFrame( + byte[] frame, + int width, + int height, + bool isIFrame, + VideoCodec codec) { + bool codecChanged; lock (_lock) { _width = width; _height = height; + codecChanged = !_codecKnown || _codec != codec; + if (codecChanged) + { + _codec = codec; + _codecKnown = true; + _vps = null; + _sps = null; + _pps = null; + _lastIFrame = null; + _cachedJpeg = null; + } } + if (codecChanged) + LogUtils.debug($"[SNAP] codec={codec}"); + + ExtractParameterSets(frame, codec); + if (isIFrame) { - ExtractSpsAndPps(h264Frame); - lock (_lock) { _lastIFrame = (byte[])h264Frame.Clone(); } + lock (_lock) { _lastIFrame = (byte[])frame.Clone(); } } if (!_mjpegActive) return; @@ -87,9 +114,18 @@ public void UpdateFrame(byte[] h264Frame, int width, int height, bool isIFrame) { try { + EnsureFFmpegPipe(codec); lock (_ffmpegLock) { - _ffmpegStdin?.Write(h264Frame, 0, h264Frame.Length); + if (_ffmpegAwaitingKeyframe && !isIFrame) + return; + + byte[] pipeFrame = isIFrame && HasParameterSets(codec) + ? PrependParameterSets(frame, codec) + : frame; + + _ffmpegAwaitingKeyframe = false; + _ffmpegStdin?.Write(pipeFrame, 0, pipeFrame.Length); _ffmpegStdin?.Flush(); } } @@ -97,7 +133,15 @@ public void UpdateFrame(byte[] h264Frame, int width, int height, bool isIFrame) } else { - _queue.Writer.TryWrite(((byte[])h264Frame.Clone(), isIFrame)); + if (codec == VideoCodec.H264) + { + _queue.Writer.TryWrite(((byte[])frame.Clone(), isIFrame)); + } + else if (!_reportedMissingHevcDecoder) + { + Console.Error.WriteLine("[SNAP] FFmpeg is required to decode H.265 snapshots"); + _reportedMissingHevcDecoder = true; + } } } @@ -121,18 +165,32 @@ public async Task GetSnapshotAsync(int timeoutMs = 5000) try { byte[] iFrame; - int w, h; - lock (_lock) { iFrame = _lastIFrame; w = _width; h = _height; } + VideoCodec codec; + lock (_lock) + { + iFrame = _lastIFrame; + codec = _codec; + } + + if (iFrame == null || !HasParameterSets(codec)) + { + lock (_lock) { return _cachedJpeg; } + } - if (iFrame == null || _sps == null || _pps == null) + if (codec == VideoCodec.H265 && !_useFFmpeg) { + if (!_reportedMissingHevcDecoder) + { + Console.Error.WriteLine("[SNAP] FFmpeg is required to decode H.265 snapshots"); + _reportedMissingHevcDecoder = true; + } lock (_lock) { return _cachedJpeg; } } - byte[] input = PrependSpsAndPps(iFrame); + byte[] input = PrependParameterSets(iFrame, codec); byte[] jpeg = _useFFmpeg - ? await DecodeOneFrameFFmpeg(input) + ? await DecodeOneFrameFFmpeg(input, codec) : DecodeH264Sharp(input, isIFrame: true); if (jpeg != null) @@ -159,22 +217,26 @@ private async Task DecodeLoop(CancellationToken ct) if (_useFFmpeg) return; - await foreach (var (data, isIFrame) in _queue.Reader.ReadAllAsync(ct)) + try { - try + await foreach (var (data, isIFrame) in _queue.Reader.ReadAllAsync(ct)) { - var jpeg = DecodeH264Sharp(data, isIFrame); - if (jpeg != null) + try { - lock (_lock) { _cachedJpeg = jpeg; } - Notify(jpeg); + var jpeg = DecodeH264Sharp(data, isIFrame); + if (jpeg != null) + { + lock (_lock) { _cachedJpeg = jpeg; } + Notify(jpeg); + } + } + catch (Exception ex) + { + LogUtils.debug($"[SNAP] DecodeLoop error: {ex.Message}"); } - } - catch (Exception ex) - { - LogUtils.debug($"[SNAP] DecodeLoop error: {ex.Message}"); } } + catch (OperationCanceledException) { } } // ── H264Sharp ───────────────────────────────────────────── @@ -191,7 +253,7 @@ private byte[] DecodeH264Sharp(byte[] h264Data, bool isIFrame) lock (_lock) { w = _width; h = _height; } byte[] input = (isIFrame && _sps != null && _pps != null) - ? PrependSpsAndPps(h264Data) + ? PrependParameterSets(h264Data, VideoCodec.H264) : h264Data; var rgb = new RgbImage(ImageFormat.Bgr, w, h); @@ -213,13 +275,30 @@ private byte[] DecodeH264Sharp(byte[] h264Data, bool isIFrame) // ── FFmpeg persistent pipe ──────────────────────────────── - private void StartFFmpegPipe() + private void EnsureFFmpegPipe(VideoCodec codec) { + lock (_ffmpegLock) + { + bool running = false; + try { running = _ffmpegProc != null && !_ffmpegProc.HasExited; } + catch { } + + if (running && _ffmpegCodec == codec && _ffmpegStdin != null) + return; + + StopFFmpegPipe(); + StartFFmpegPipe(codec); + } + } + + private void StartFFmpegPipe(VideoCodec codec) + { + string inputFormat = codec == VideoCodec.H265 ? "hevc" : "h264"; var psi = new ProcessStartInfo { FileName = "ffmpeg", Arguments = "-hide_banner -loglevel error " + - "-f h264 -i pipe:0 " + + $"-f {inputFormat} -i pipe:0 " + "-q:v 4 -f image2pipe -vcodec mjpeg pipe:1", UseShellExecute = false, RedirectStandardInput = true, @@ -229,21 +308,47 @@ private void StartFFmpegPipe() _ffmpegProc = Process.Start(psi)!; _ffmpegStdin = _ffmpegProc.StandardInput.BaseStream; + _ffmpegCodec = codec; + _ffmpegAwaitingKeyframe = true; + Stream output = _ffmpegProc.StandardOutput.BaseStream; + + Task.Run(() => ReadFFmpegOutput(output, _cts.Token)); + LogUtils.debug($"[SNAP] FFmpeg {inputFormat} pipe started"); + } - Task.Run(() => ReadFFmpegOutput(_ffmpegProc.StandardOutput.BaseStream, _cts.Token)); - LogUtils.debug("[SNAP] FFmpeg pipe started"); + private void StopFFmpegPipe() + { + try { _ffmpegStdin?.Close(); } catch { } + try + { + if (_ffmpegProc != null && !_ffmpegProc.HasExited) + _ffmpegProc.WaitForExit(1000); + } + catch { } + try + { + if (_ffmpegProc != null && !_ffmpegProc.HasExited) + _ffmpegProc.Kill(); + } + catch { } + _ffmpegProc?.Dispose(); + _ffmpegProc = null; + _ffmpegStdin = null; + _ffmpegCodec = null; + _ffmpegAwaitingKeyframe = true; } - private async Task DecodeOneFrameFFmpeg(byte[] h264Data) + private async Task DecodeOneFrameFFmpeg(byte[] videoData, VideoCodec codec) { try { + string inputFormat = codec == VideoCodec.H265 ? "hevc" : "h264"; var psi = new ProcessStartInfo { FileName = "ffmpeg", Arguments = "-hide_banner -loglevel error " + - "-f h264 -i pipe:0 " + - "-frames:v 1 -q:v 2 -f image2 pipe:1", + $"-f {inputFormat} -i pipe:0 " + + "-frames:v 1 -q:v 2 -f image2 -vcodec mjpeg pipe:1", UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, @@ -251,7 +356,7 @@ private async Task DecodeOneFrameFFmpeg(byte[] h264Data) }; using var proc = Process.Start(psi)!; - await proc.StandardInput.BaseStream.WriteAsync(h264Data); + await proc.StandardInput.BaseStream.WriteAsync(videoData); proc.StandardInput.Close(); using var ms = new MemoryStream(); @@ -313,50 +418,89 @@ private static int FindBytes(List buf, byte b0, byte b1, int from = 0) // ── helpers ─────────────────────────────────────────────── - private void ExtractSpsAndPps(byte[] h264Data) + private void ExtractParameterSets(byte[] data, VideoCodec codec) { - var nals = FindNalUnits(h264Data); - foreach (var nal in nals) + AnnexB.Parse(data, codec, (nalType, nal) => { - if (nal.Length == 0) continue; - int t = nal[0] & 0x1F; - if (t == 7) { _sps = (byte[])nal.Clone(); LogUtils.debug($"[SNAP] SPS {_sps.Length}b"); } - if (t == 8) { _pps = (byte[])nal.Clone(); LogUtils.debug($"[SNAP] PPS {_pps.Length}b"); } - } - } - - private static List FindNalUnits(byte[] data) - { - var result = new List(); - int i = 0; - while (i < data.Length - 4) - { - if (data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 0 && data[i + 3] == 1) + lock (_lock) { - int start = i + 4, end = start; - while (end < data.Length - 4) + if (codec == VideoCodec.H264) + { + if (nalType == 7) + { + bool changed = _sps == null || !_sps.SequenceEqual(nal); + _sps = nal; + if (changed) LogUtils.debug($"[SNAP] H264 SPS {_sps.Length}b"); + } + else if (nalType == 8) + { + bool changed = _pps == null || !_pps.SequenceEqual(nal); + _pps = nal; + if (changed) LogUtils.debug($"[SNAP] H264 PPS {_pps.Length}b"); + } + return; + } + + if (nalType == 32) + { + bool changed = _vps == null || !_vps.SequenceEqual(nal); + _vps = nal; + if (changed) LogUtils.debug($"[SNAP] H265 VPS {_vps.Length}b"); + } + else if (nalType == 33) { - if (data[end] == 0 && data[end + 1] == 0 && data[end + 2] == 0 && data[end + 3] == 1) break; - end++; + bool changed = _sps == null || !_sps.SequenceEqual(nal); + _sps = nal; + if (changed) LogUtils.debug($"[SNAP] H265 SPS {_sps.Length}b"); + } + else if (nalType == 34) + { + bool changed = _pps == null || !_pps.SequenceEqual(nal); + _pps = nal; + if (changed) LogUtils.debug($"[SNAP] H265 PPS {_pps.Length}b"); } - if (end >= data.Length - 4) end = data.Length; - var nal = new byte[end - start]; - Array.Copy(data, start, nal, 0, nal.Length); - result.Add(nal); - i = end; } - else i++; + }); + } + + private bool HasParameterSets(VideoCodec codec) + { + lock (_lock) + { + return codec == VideoCodec.H265 + ? _vps != null && _sps != null && _pps != null + : _sps != null && _pps != null; } - return result; } - private byte[] PrependSpsAndPps(byte[] idrFrame) + private byte[] PrependParameterSets(byte[] keyframe, VideoCodec codec) { byte[] sc = { 0x00, 0x00, 0x00, 0x01 }; + byte[] vps, sps, pps; + lock (_lock) + { + vps = _vps; + sps = _sps; + pps = _pps; + } + using var ms = new MemoryStream(); - ms.Write(sc); ms.Write(_sps); - ms.Write(sc); ms.Write(_pps); - ms.Write(idrFrame); + if (codec == VideoCodec.H265 && vps != null) + { + ms.Write(sc); + ms.Write(vps); + } + if (sps != null) + { + ms.Write(sc); + ms.Write(sps); + } + if (pps != null) + { + ms.Write(sc); + ms.Write(pps); + } + ms.Write(keyframe); return ms.ToArray(); } @@ -409,10 +553,8 @@ public void Dispose() _cts.Cancel(); _queue.Writer.Complete(); - try { _ffmpegStdin?.Close(); } catch { } - try { _ffmpegProc?.WaitForExit(2000); } catch { } - try { _ffmpegProc?.Kill(); } catch { } - _ffmpegProc?.Dispose(); + lock (_ffmpegLock) + StopFFmpegPipe(); _decoder?.Dispose(); } @@ -422,4 +564,4 @@ private class Subscription(Action onDispose) : IDisposable public void Dispose() => onDispose(); } } -} \ No newline at end of file +} diff --git a/src/V380Client.cs b/src/V380Client.cs index 028af88..24305dd 100644 --- a/src/V380Client.cs +++ b/src/V380Client.cs @@ -23,6 +23,8 @@ public class V380Client : IDisposable private byte[] aesKey = new byte[16]; private bool needReconnect = false; private DeviceInfo deviceInfo; + private readonly VideoPayloadDecoder videoPayloadDecoder; + private bool reportedExtendedAudio; public V380Client(string ip, int port, uint deviceId, string username, string password, SourceStream source, OutputMode mode, bool enableMjpeg) @@ -35,6 +37,7 @@ public V380Client(string ip, int port, uint deviceId, string username, string pa this.source = source; this.mode = mode; this.enableMjpeg = enableMjpeg; + videoPayloadDecoder = new VideoPayloadDecoder(aesKey); snapshotManager = new SnapshotManager(); snapshotManager.SetMjpegActive(enableMjpeg); } @@ -143,7 +146,7 @@ public int GetAuthTicket() return 0; } uint loginResult = ReadUInt32LE(resp, 4); - if (loginResult != 1001) + if (loginResult is not (1001 or 1002)) { if (loginResult == 1011) Console.Error.WriteLine($"[AUTH] invalid username. exiting..."); @@ -152,7 +155,7 @@ public int GetAuthTicket() else if (loginResult == 1018) Console.Error.WriteLine($"[AUTH] invalid device id. exiting..."); else - Console.Error.WriteLine($"[AUTH] login failed result: {loginResult} (expected 1001). exiting..."); + Console.Error.WriteLine($"[AUTH] login failed result: {loginResult} (expected 1001 or 1002). exiting..."); return -1; } @@ -204,6 +207,7 @@ public int GetAuthTicket() public bool StreamLogin() { + communicationVersion = 0; streamClient = new TcpClient { NoDelay = true, @@ -273,30 +277,47 @@ public bool StreamLogin() LogUtils.debug($"[STREAM] login response success"); LogUtils.debug($"[STREAM] login cmd: {respCmd}"); - if (source == SourceStream.Lan) + + // The relay returns the same media protocol version field as a LAN + // camera. This used to be parsed only for LAN, leaving cloud streams + // at version 0 and selecting the wrong AES layout for version 21. + if (resp401.Length >= 10) { ushort version = ReadUInt16LE(resp401, 8); - uint width = ReadUInt32LE(resp401, 10); - uint height = ReadUInt32LE(resp401, 14); - uint maxPackSize = ReadUInt32LE(resp401, 18); - byte audioFreq = resp401[22]; - byte audioBits = resp401[23]; - byte audioChannels = resp401[24]; - communicationVersion = version; - frameWidth = (int)width; - frameheight = (int)height; LogUtils.debug($"[STREAM] login result: {result}"); LogUtils.debug($"[STREAM] login version: {version}"); - LogUtils.debug($"[STREAM] login width: {width}"); - LogUtils.debug($"[STREAM] login height: {height}"); - LogUtils.debug($"[STREAM] login maxPackSize: {maxPackSize}"); - LogUtils.debug($"[STREAM] login audioFreq: {audioFreq}"); - LogUtils.debug($"[STREAM] login audioBits: {audioBits}"); - LogUtils.debug($"[STREAM] login audioChannels: {audioChannels}"); + + if (version is 20 or 21) + communicationVersion = version; + + if (resp401.Length >= 25) + { + uint width = ReadUInt32LE(resp401, 10); + uint height = ReadUInt32LE(resp401, 14); + uint maxPackSize = ReadUInt32LE(resp401, 18); + byte audioFreq = resp401[22]; + byte audioBits = resp401[23]; + byte audioChannels = resp401[24]; + + if (width is >= 160 and <= 16384 && height is >= 120 and <= 16384) + { + frameWidth = (int)width; + frameheight = (int)height; + } + + LogUtils.debug($"[STREAM] login width: {width}"); + LogUtils.debug($"[STREAM] login height: {height}"); + LogUtils.debug($"[STREAM] login maxPackSize: {maxPackSize}"); + LogUtils.debug($"[STREAM] login audioFreq: {audioFreq}"); + LogUtils.debug($"[STREAM] login audioBits: {audioBits}"); + LogUtils.debug($"[STREAM] login audioChannels: {audioChannels}"); + } } + videoPayloadDecoder.Reset(communicationVersion); + if (deviceVersion > 30) GenerateMediaKey(authTicket); - Console.Error.WriteLine($"[STREAM] login OK"); + Console.Error.WriteLine($"[STREAM] login OK communicationVersion={communicationVersion}"); return true; } @@ -312,10 +333,8 @@ public bool StartStream() public void ReceiveFrames(OutputMode mode, RtspServer rtsp, CancellationToken ct) { bool needDecrypt = deviceVersion > 30; - - var videoFrags = new List(); - var audioFrags = new List(); - ushort videoTotal = 0, audioTotal = 0; + var videoAssembler = new FragmentAssembler("VIDEO"); + var audioAssembler = new FragmentAssembler("AUDIO"); var header12 = new byte[12]; var payloadBuf = new byte[65536]; @@ -362,128 +381,32 @@ public void ReceiveFrames(OutputMode mode, RtspServer rtsp, CancellationToken ct if (payloadBuf.Length < payLen) payloadBuf = new byte[payLen]; if (ReadExact(streamStream, payloadBuf, 0, payLen) < payLen) continue; - // VIDEO 0x00=I-frame 0x01=P-frame - if (type == 0x00 || type == 0x01) + if (type == 0x5B) { - if (curFrame == 0) { videoFrags.Clear(); videoTotal = totalFrame; } - if (totalFrame != videoTotal) { videoFrags.Clear(); videoTotal = totalFrame; } - - for (int i = 0; i < payLen; i++) videoFrags.Add(payloadBuf[i]); - - if (curFrame != totalFrame - 1) continue; - if (videoFrags.Count < 16) { videoFrags.Clear(); continue; } - - byte[] full = videoFrags.ToArray(); - videoFrags.Clear(); - - //parse inner 16-byte frame header - uint frameId = ReadUInt32LE(full, 0); - ushort frameType = ReadUInt16LE(full, 4); - ushort frameRate = ReadUInt16LE(full, 6); - ulong timestamp = ReadUInt64LE(full, 8); - - byte[] payload = new byte[full.Length - 16]; - Array.Copy(full, 16, payload, 0, payload.Length); - - if (needDecrypt) - { - if (communicationVersion == 21) - DecryptMediaPre2k(payload, payload.Length, 1); - else - DecryptVideoFrame(payload, payload.Length); - } - - // validate H.264 start code - if (payload.Length < 4 || - payload[0] != 0 || payload[1] != 0 || - payload[2] != 0 || payload[3] != 1) - { - Console.Error.WriteLine($"[VIDEO] bad start code, len={payload.Length}"); - continue; - } - - snapshotManager.UpdateFrame(payload, frameWidth, frameheight, isIFrame: type == 0x00); - - var fd = new FrameData - { - RawType = type, - FrameId = frameId, - FrameType = frameType, - FrameRate = frameRate, - Timestamp = timestamp, - Payload = payload - }; - - if (mode == OutputMode.Video) - { - stdout.Write(payload, 0, payload.Length); - stdout.Flush(); - } - else if (mode == OutputMode.Rtsp) - { - rtsp?.PushVideo(fd); - } + continue; } - // AUDIO 0x1A - else if (type == 0x1A) - { - if (curFrame == 0) { audioFrags.Clear(); audioTotal = totalFrame; } - if (totalFrame != audioTotal) { audioFrags.Clear(); audioTotal = totalFrame; } - - for (int i = 0; i < payLen; i++) audioFrags.Add(payloadBuf[i]); - - if (curFrame != totalFrame - 1) continue; - if (audioFrags.Count < 16) { audioFrags.Clear(); continue; } - - byte[] full = audioFrags.ToArray(); - audioFrags.Clear(); - - //parse inner 16-byte frame header - uint frameId = ReadUInt32LE(full, 0); - ushort frameType = ReadUInt16LE(full, 4); - ushort frameRate = ReadUInt16LE(full, 6); - ulong timestamp = ReadUInt64LE(full, 8); - - byte[] payload = new byte[full.Length - 16]; - Array.Copy(full, 16, payload, 0, payload.Length); - - if (needDecrypt) - { - if (communicationVersion == 21) - DecryptMediaPre2k(payload, payload.Length, 1); - else - DecryptAudioFrame(payload, payload.Length); - } - - var fd = new FrameData - { - RawType = type, - FrameId = frameId, - FrameType = frameType, - FrameRate = frameRate, - Timestamp = timestamp, - Payload = payload - }; - - if (mode == OutputMode.Audio) - { - stdout.Write(payload, 0, payload.Length); - stdout.Flush(); - } - else if (mode == OutputMode.Rtsp) - { - rtsp?.PushAudio(fd); - } - } - else if (type == 0x5B) + // Legacy cameras use 0x00/0x01 for H.264. Newer HEVC + // cameras use 0x28/0x29 for key/inter frames. + if (type is 0x00 or 0x01 or 0x28 or 0x29) { + if (videoAssembler.TryAppend( + type, totalFrame, curFrame, payloadBuf, payLen, out byte[] full)) + HandleVideoFrame(type, full, needDecrypt, mode, rtsp, stdout); continue; } - else + + // 0x1A is the legacy PCMA stream. Type 0x16 has a + // four-byte-longer inner header and is used by newer models. + if (type is 0x1A or 0x16) { - Console.Error.WriteLine($"[FRAME] unknown type=0x{type:X2} len={payLen}"); + if (audioAssembler.TryAppend( + type, totalFrame, curFrame, payloadBuf, payLen, out byte[] full)) + HandleAudioFrame(type, full, needDecrypt, mode, rtsp, stdout); + continue; } + + Console.Error.WriteLine($"[FRAME] unknown type=0x{type:X2} len={payLen}"); } } catch (OperationCanceledException) @@ -496,14 +419,137 @@ public void ReceiveFrames(OutputMode mode, RtspServer rtsp, CancellationToken ct } } - private void DecryptVideoFrame(byte[] data, int length) + private void HandleVideoFrame( + byte rawType, + byte[] full, + bool needDecrypt, + OutputMode outputMode, + RtspServer rtsp, + Stream stdout) { - using var aes = Aes.Create(); - aes.Key = aesKey; aes.Mode = CipherMode.ECB; aes.Padding = PaddingMode.None; - using var dec = aes.CreateDecryptor(); - for (int offset = 0; offset + 64 <= length; offset += 80) - for (int i = 0; i < 4; i++) - dec.TransformBlock(data, offset + i * 16, 16, data, offset + i * 16); + if (full.Length <= 16) + { + Console.Error.WriteLine($"[VIDEO] short frame type=0x{rawType:X2} len={full.Length}"); + return; + } + + VideoCodec codec = rawType is 0x28 or 0x29 + ? VideoCodec.H265 + : VideoCodec.H264; + + if (!TryExtractVideoPayload(full, codec, needDecrypt, out byte[] payload)) + { + Console.Error.WriteLine( + $"[VIDEO] no valid {codec} Annex-B payload type=0x{rawType:X2} len={full.Length}"); + return; + } + + uint frameId = ReadUInt32LE(full, 0); + ushort frameType = ReadUInt16LE(full, 4); + ushort frameRate = ReadUInt16LE(full, 6); + ulong timestamp = ReadUInt64LE(full, 8); + bool isKeyframe = rawType is 0x00 or 0x28; + + snapshotManager.UpdateFrame( + payload, frameWidth, frameheight, isKeyframe, codec); + + var frame = new FrameData + { + RawType = rawType, + FrameId = frameId, + FrameType = frameType, + FrameRate = frameRate, + Timestamp = timestamp, + Codec = codec, + Payload = payload + }; + + if (outputMode == OutputMode.Video) + { + stdout?.Write(payload, 0, payload.Length); + stdout?.Flush(); + } + else if (outputMode == OutputMode.Rtsp) + { + rtsp?.PushVideo(frame); + } + } + + private void HandleAudioFrame( + byte rawType, + byte[] full, + bool needDecrypt, + OutputMode outputMode, + RtspServer rtsp, + Stream stdout) + { + int headerSize = rawType == 0x16 ? 20 : 16; + if (full.Length <= headerSize) return; + + uint frameId = ReadUInt32LE(full, 0); + ushort frameType = ReadUInt16LE(full, 4); + ushort frameRate = ReadUInt16LE(full, 6); + ulong timestamp = ReadUInt64LE(full, 8); + + byte[] payload = new byte[full.Length - headerSize]; + Array.Copy(full, headerSize, payload, 0, payload.Length); + + if (needDecrypt) + { + if (communicationVersion == 21) + DecryptMediaPre2k(payload, payload.Length, 1); + else + DecryptAudioFrame(payload, payload.Length); + } + + if (outputMode == OutputMode.Audio) + { + stdout?.Write(payload, 0, payload.Length); + stdout?.Flush(); + return; + } + + if (outputMode != OutputMode.Rtsp) return; + + if (rawType == 0x16) + { + if (!reportedExtendedAudio) + { + Console.Error.WriteLine( + "[AUDIO] type 0x16 received; RTSP output is skipped because it is not PCMA"); + reportedExtendedAudio = true; + } + return; + } + + rtsp?.PushAudio(new FrameData + { + RawType = rawType, + FrameId = frameId, + FrameType = frameType, + FrameRate = frameRate, + Timestamp = timestamp, + Payload = payload + }); + } + + private bool TryExtractVideoPayload( + byte[] full, + VideoCodec codec, + bool needDecrypt, + out byte[] payload) + { + bool decoded = videoPayloadDecoder.TryDecode( + full, codec, needDecrypt, out payload, out bool selectionChanged); + + if (decoded && selectionChanged) + { + Console.Error.WriteLine( + $"[VIDEO] codec={codec} decrypt={videoPayloadDecoder.SelectedMode} " + + $"header={videoPayloadDecoder.SelectedHeaderSize} score={videoPayloadDecoder.LastScore}"); + } + + return decoded; } private void DecryptAudioFrame(byte[] data, int length) @@ -543,6 +589,82 @@ private void DecryptMediaPre2k(byte[] data, int length, int mode) } } + private sealed class FragmentAssembler + { + private const int MaxFrameBytes = 32 * 1024 * 1024; + private readonly string mediaName; + private readonly List bytes = new(); + private bool active; + private byte type; + private ushort total; + private ushort next; + + public FragmentAssembler(string mediaName) + { + this.mediaName = mediaName; + } + + public bool TryAppend( + byte fragmentType, + ushort fragmentTotal, + ushort fragmentIndex, + byte[] data, + int length, + out byte[] frame) + { + frame = Array.Empty(); + + if (fragmentIndex == 0) + { + Reset(); + active = true; + type = fragmentType; + total = fragmentTotal; + } + else if (!active || + fragmentType != type || + fragmentTotal != total || + fragmentIndex != next) + { + Console.Error.WriteLine( + $"[{mediaName}] fragment gap type=0x{fragmentType:X2} " + + $"expected={next} got={fragmentIndex} total={fragmentTotal}"); + Reset(); + return false; + } + + if (!active || fragmentIndex != next) + return false; + + if (bytes.Count + length > MaxFrameBytes) + { + Console.Error.WriteLine($"[{mediaName}] frame exceeds {MaxFrameBytes} bytes"); + Reset(); + return false; + } + + for (int i = 0; i < length; i++) + bytes.Add(data[i]); + + next = (ushort)(fragmentIndex + 1); + if (fragmentIndex != fragmentTotal - 1) + return false; + + frame = bytes.ToArray(); + Reset(); + return true; + } + + private void Reset() + { + bytes.Clear(); + active = false; + type = 0; + total = 0; + next = 0; + } + } + void GenerateMediaKey(uint ticket) { WriteUInt32LE(aesKey, 0, ticket); @@ -687,4 +809,4 @@ public void Dispose() snapshotManager?.Dispose(); } } -} \ No newline at end of file +} diff --git a/src/VideoPayloadDecoder.cs b/src/VideoPayloadDecoder.cs new file mode 100644 index 0000000..e434fc7 --- /dev/null +++ b/src/VideoPayloadDecoder.cs @@ -0,0 +1,190 @@ +using System.Security.Cryptography; + +namespace V380Decoder.src +{ + internal enum VideoDecryptMode + { + None, + Selective64Of80, + FullBlocks + } + + internal sealed class VideoPayloadDecoder + { + private readonly byte[] aesKey; + private bool selectionConfirmed; + + public VideoDecryptMode? SelectedMode { get; private set; } + public int SelectedHeaderSize { get; private set; } = 16; + public int LastScore { get; private set; } = int.MinValue; + + public VideoPayloadDecoder(byte[] aesKey) + { + this.aesKey = aesKey; + } + + public void Reset(ushort communicationVersion) + { + SelectedMode = communicationVersion switch + { + 21 => VideoDecryptMode.FullBlocks, + 20 => VideoDecryptMode.Selective64Of80, + _ => null + }; + SelectedHeaderSize = 16; + LastScore = int.MinValue; + selectionConfirmed = false; + } + + public bool TryDecode( + byte[] frame, + VideoCodec codec, + bool encrypted, + out byte[] payload, + out bool selectionChanged) + { + if (selectionConfirmed && SelectedMode.HasValue && + TryCandidate( + frame, + codec, + SelectedMode.Value, + SelectedHeaderSize, + out payload, + out int confirmedScore) && + confirmedScore >= 10) + { + LastScore = confirmedScore; + selectionChanged = false; + return true; + } + + selectionConfirmed = false; + var candidates = new List<(VideoDecryptMode mode, int headerSize)>(); + + void AddCandidate(VideoDecryptMode mode, int headerSize) + { + if (!candidates.Contains((mode, headerSize))) + candidates.Add((mode, headerSize)); + } + + if (encrypted) + { + if (SelectedMode.HasValue) + AddCandidate(SelectedMode.Value, SelectedHeaderSize); + + foreach (int headerSize in new[] { 16, 20 }) + { + AddCandidate(VideoDecryptMode.Selective64Of80, headerSize); + AddCandidate(VideoDecryptMode.FullBlocks, headerSize); + AddCandidate(VideoDecryptMode.None, headerSize); + } + } + else + { + AddCandidate(VideoDecryptMode.None, 16); + AddCandidate(VideoDecryptMode.None, 20); + } + + int bestScore = int.MinValue; + byte[] bestPayload = null; + VideoDecryptMode bestMode = VideoDecryptMode.None; + int bestHeaderSize = 16; + + foreach (var candidate in candidates) + { + if (!TryCandidate( + frame, + codec, + candidate.mode, + candidate.headerSize, + out byte[] normalized, + out int score)) + continue; + + if (score <= bestScore) continue; + + bestScore = score; + bestPayload = normalized; + bestMode = candidate.mode; + bestHeaderSize = candidate.headerSize; + } + + if (bestPayload == null || bestScore < 10) + { + payload = Array.Empty(); + selectionChanged = false; + LastScore = bestScore; + return false; + } + + selectionChanged = + SelectedMode != bestMode || + SelectedHeaderSize != bestHeaderSize; + + SelectedMode = bestMode; + SelectedHeaderSize = bestHeaderSize; + LastScore = bestScore; + selectionConfirmed = true; + payload = bestPayload; + return true; + } + + private bool TryCandidate( + byte[] frame, + VideoCodec codec, + VideoDecryptMode mode, + int headerSize, + out byte[] payload, + out int score) + { + if (frame.Length <= headerSize) + { + payload = Array.Empty(); + score = int.MinValue; + return false; + } + + var decoded = new byte[frame.Length - headerSize]; + Array.Copy(frame, headerSize, decoded, 0, decoded.Length); + Decrypt(decoded, mode); + + int leadingBytes = AnnexB.FindStartCode(decoded, 0); + if (!AnnexB.TryNormalize(decoded, out payload)) + { + score = int.MinValue; + return false; + } + + score = AnnexB.Score(payload, codec) - leadingBytes * 5; + return score > int.MinValue; + } + + private void Decrypt(byte[] data, VideoDecryptMode mode) + { + if (mode == VideoDecryptMode.None) return; + + using var aes = Aes.Create(); + aes.Key = aesKey; + aes.Mode = CipherMode.ECB; + aes.Padding = PaddingMode.None; + using var decryptor = aes.CreateDecryptor(); + + if (mode == VideoDecryptMode.FullBlocks) + { + int decryptLength = data.Length / 16 * 16; + if (decryptLength > 0) + decryptor.TransformBlock(data, 0, decryptLength, data, 0); + return; + } + + for (int offset = 0; offset + 64 <= data.Length; offset += 80) + { + for (int block = 0; block < 4; block++) + { + int blockOffset = offset + block * 16; + decryptor.TransformBlock(data, blockOffset, 16, data, blockOffset); + } + } + } + } +} diff --git a/tests/V380Decoder.Tests/V380Decoder.Tests.csproj b/tests/V380Decoder.Tests/V380Decoder.Tests.csproj new file mode 100644 index 0000000..2abbd5a --- /dev/null +++ b/tests/V380Decoder.Tests/V380Decoder.Tests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + disable + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/tests/V380Decoder.Tests/VideoPayloadDecoderTests.cs b/tests/V380Decoder.Tests/VideoPayloadDecoderTests.cs new file mode 100644 index 0000000..380cfb1 --- /dev/null +++ b/tests/V380Decoder.Tests/VideoPayloadDecoderTests.cs @@ -0,0 +1,136 @@ +using System.Security.Cryptography; +using V380Decoder.src; +using Xunit; + +namespace V380Decoder.Tests; + +public class VideoPayloadDecoderTests +{ + [Theory] + [InlineData((int)VideoDecryptMode.Selective64Of80, 16)] + [InlineData((int)VideoDecryptMode.FullBlocks, 16)] + [InlineData((int)VideoDecryptMode.Selective64Of80, 20)] + [InlineData((int)VideoDecryptMode.FullBlocks, 20)] + public void AutoDetectsHevcEncryptionAndHeader( + int encryptionModeValue, + int headerSize) + { + var encryptionMode = (VideoDecryptMode)encryptionModeValue; + byte[] key = Enumerable.Range(1, 16).Select(i => (byte)i).ToArray(); + byte[] original = BuildHevcAccessUnit(); + byte[] encrypted = Encrypt(original, key, encryptionMode); + byte[] frame = new byte[headerSize + encrypted.Length]; + Array.Copy(encrypted, 0, frame, headerSize, encrypted.Length); + + var decoder = new VideoPayloadDecoder(key); + decoder.Reset(communicationVersion: 0); + + bool success = decoder.TryDecode( + frame, + VideoCodec.H265, + encrypted: true, + out byte[] decoded, + out _); + + Assert.True(success); + Assert.Equal(original, decoded); + Assert.Equal(encryptionMode, decoder.SelectedMode); + Assert.Equal(headerSize, decoder.SelectedHeaderSize); + } + + [Fact] + public void CommunicationVersion21UsesFullBlockDecryption() + { + byte[] key = Enumerable.Range(17, 16).Select(i => (byte)i).ToArray(); + byte[] original = BuildHevcAccessUnit(); + byte[] encrypted = Encrypt(original, key, VideoDecryptMode.FullBlocks); + byte[] frame = new byte[16 + encrypted.Length]; + Array.Copy(encrypted, 0, frame, 16, encrypted.Length); + + var decoder = new VideoPayloadDecoder(key); + decoder.Reset(communicationVersion: 21); + + Assert.True(decoder.TryDecode( + frame, + VideoCodec.H265, + encrypted: true, + out byte[] decoded, + out _)); + Assert.Equal(original, decoded); + Assert.Equal(VideoDecryptMode.FullBlocks, decoder.SelectedMode); + } + + [Fact] + public void RejectsPayloadWithoutAnnexBUnits() + { + byte[] key = new byte[16]; + var decoder = new VideoPayloadDecoder(key); + decoder.Reset(communicationVersion: 0); + + Assert.False(decoder.TryDecode( + Enumerable.Repeat((byte)0xAA, 512).ToArray(), + VideoCodec.H265, + encrypted: true, + out _, + out _)); + } + + [Fact] + public void ParsesDimensionsFromRealHevcSps() + { + byte[] sps = null; + AnnexB.Parse( + BuildHevcAccessUnit(), + VideoCodec.H265, + (type, nal) => + { + if (type == 33) sps = nal; + }); + + Assert.NotNull(sps); + Assert.True(HevcSpsParser.TryGetDimensions(sps, out int width, out int height)); + Assert.Equal(64, width); + Assert.Equal(64, height); + } + + private static byte[] BuildHevcAccessUnit() + { + // One real 64x64 x265 keyframe with VPS/SPS/PPS and no metadata SEI. + return Convert.FromBase64String( + "AAAAAUABDAH//wFgAAADAJAAAAMAAAMAHpWYCQAAAAFCAQEBYAAAAwCQ" + + "AAADAAADAB6gIIEFllZpJMrwFoCAAAADAIAAAAMAhAAAAAFEAcFytCJ" + + "AAAABKAGvE4DmaOP//RfPx/bP"); + } + + private static byte[] Encrypt( + byte[] plaintext, + byte[] key, + VideoDecryptMode mode) + { + byte[] encrypted = (byte[])plaintext.Clone(); + using var aes = Aes.Create(); + aes.Key = key; + aes.Mode = CipherMode.ECB; + aes.Padding = PaddingMode.None; + using var encryptor = aes.CreateEncryptor(); + + if (mode == VideoDecryptMode.FullBlocks) + { + int length = encrypted.Length / 16 * 16; + encryptor.TransformBlock(encrypted, 0, length, encrypted, 0); + return encrypted; + } + + for (int offset = 0; offset + 64 <= encrypted.Length; offset += 80) + { + for (int block = 0; block < 4; block++) + { + int blockOffset = offset + block * 16; + encryptor.TransformBlock( + encrypted, blockOffset, 16, encrypted, blockOffset); + } + } + + return encrypted; + } +} From a1275285aca8b6e12a0777df66257ed5800a8c21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8=D1=87=20?= =?UTF-8?q?=D0=98=D0=B2=D0=B0=D0=BD=D0=BE=D0=B2?= <32629144+AspirantDrago@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:10:50 +0500 Subject: [PATCH 2/2] Fix release workflow permissions --- .github/workflows/release.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d8cbc8e..7db5bd3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,9 +7,8 @@ on: workflow_dispatch: inputs: version: - description: 'Version (e.g., v1.0.0)' + description: 'Version (e.g., v1.0.4)' required: true - default: 'v1.0.0' jobs: build: @@ -120,6 +119,8 @@ jobs: name: Create Release needs: build runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout @@ -173,7 +174,7 @@ jobs: EOF - name: Create release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.version.outputs.tag }} name: ${{ steps.version.outputs.tag }}