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 }}