@discordjs/voice only decrypts/forwards AUDIO (opus) — its onUdpMessage drops every non-opus RTP packet (dist/index.mjs:2068 `!== RTP_OPUS_PAYLOAD_TYPE`, 120). Video RTP (H264 camera + screen share, plus VP8/VP9/AV1) arrives on the same UDP socket but was silently discarded. New videoReceiver.ts wraps receiver.onUdpMessage (like screenShareAudio.ts): - detects video payload types (96/98/101/102/106/116/126/127), - decrypts them with the connection secret key/encryptionMode via the SAME receiver.parsePacket path @discordjs/voice uses for audio (so DAVE + voice encryption are handled identically), - depacketizes H264 to AnnexB (single NAL, STAP-A, and FU-A fragmentation), waiting for a keyframe (SPS/PPS/IDR) before writing, - writes a raw .h264 elementary stream per user per burst under <RECORDINGS_DIR>/<uid>/video-<ssrc>-<ts>.h264. Attribution: a videoSSRC→user index is built from ssrcMap updates; a proximity fallback mirrors screenShareAudio's inferScreenShareOwner. Bot's own video is skipped. Unit tests: tests/videoReceiver.test.ts (AnnexB start code, keyframe gating, FU-A reassembly, orphan-fragment tolerance) — 5/5 green. Phase A only (capture raw h264). Phase B (ffmpeg decode+mux to MP4/WebM + persist) and Phase C (frontend playback) are follow-ups.
63 lines
2.4 KiB
TypeScript
63 lines
2.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { H264Depacketizer } from "../src/modules/voice-recording/videoReceiver.js";
|
|
|
|
// Build a single-NAL RTP payload: [NAL header, ...data]
|
|
function singleNal(nalHeader: number, data: number[] = []): Buffer {
|
|
return Buffer.from([nalHeader, ...data]);
|
|
}
|
|
|
|
describe("H264Depacketizer", () => {
|
|
it("prefixes a single NAL with an AnnexB start code once config seen", () => {
|
|
const d = new H264Depacketizer();
|
|
// SPS first (0x67 = nal ref idc 3, type 7)
|
|
const sps = d.push(singleNal(0x67, [1, 2, 3]));
|
|
expect(sps.length).toBe(1);
|
|
expect(sps[0].subarray(0, 4)).toEqual(Buffer.from([0, 0, 0, 1]));
|
|
expect(sps[0][4]).toBe(0x67); // start code + original NAL
|
|
});
|
|
|
|
it("drops NALs before seeing SPS/PPS/IDR (waits for a keyframe)", () => {
|
|
const d = new H264Depacketizer();
|
|
// A non-IDR slice (type 1) before any config → dropped
|
|
const res = d.push(singleNal(0x21, [9, 9, 9]));
|
|
expect(res.length).toBe(0);
|
|
});
|
|
|
|
it("emits frames after config", () => {
|
|
const d = new H264Depacketizer();
|
|
d.push(singleNal(0x67, [])); // SPS
|
|
const slice = d.push(singleNal(0x65, [1, 2, 3, 4])); // IDR (type 5)
|
|
expect(slice.length).toBe(1);
|
|
expect(slice[0].equals(Buffer.from([0, 0, 0, 1, 0x65, 1, 2, 3, 4]))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("reassembles FU-A fragments into one NAL", () => {
|
|
const d = new H264Depacketizer();
|
|
d.push(singleNal(0x67, [])); // SPS config
|
|
// FU-A: payload[0]=FU indicator (0x7C=type28), payload[1]=FU header
|
|
// start fragment: S=1 E=0, NAL type 5 => 0x85 ; data [10, 11]
|
|
const start = Buffer.from([0x7c, 0x85, 10, 11]);
|
|
const middle = Buffer.from([0x7c, 0x05, 12, 13]);
|
|
const end = Buffer.from([0x7c, 0x45, 14, 15]); // E=1
|
|
|
|
expect(d.push(start).length).toBe(0); // not complete yet
|
|
expect(d.push(middle).length).toBe(0);
|
|
const final = d.push(end);
|
|
expect(final.length).toBe(1);
|
|
// NAL header reconstructed as (0x7C & 0xE0) | 5 = 0x65, then data 10..15
|
|
expect(
|
|
final[0].equals(Buffer.from([0, 0, 0, 1, 0x65, 10, 11, 12, 13, 14, 15])),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("first fragment of a burst with unknown start is dropped gracefully", () => {
|
|
const d = new H264Depacketizer();
|
|
d.push(singleNal(0x67, [])); // SPS
|
|
// A continuation fragment with no buffer → ignored, no crash
|
|
const orphan = Buffer.from([0x7c, 0x05, 99]); // S=0
|
|
expect(d.push(orphan).length).toBe(0);
|
|
});
|
|
});
|