feat(rtp-capture): add two-peer RTP capture test for H264 frame transmission

This commit is contained in:
asepharyana
2026-08-12 14:26:54 +07:00
parent d9f5592e6e
commit 00fc852a32
2 changed files with 170 additions and 9 deletions
@@ -0,0 +1,138 @@
// Two-peer RTP capture test: A sends REAL H264 frames through the binding's
// packetizer chain to B over localhost. tcpdump (run externally on lo) captures
// the RTP; a Python script reassembles AnnexB and ffmpeg decodes it.
//
// Usage:
// node test-rtp-capture.js <mode> mode = "a" (sender) | "b" (receiver)
// Sender writes the negotiated SDP pieces to /tmp/rtp-a.sdp /tmp/rtp-b.sdp
// Receiver listens and keeps alive.
"use strict";
const { PeerConnection } = require("./build/Release/datachannel_min.node");
const fs = require("fs");
const mode = process.argv[2] || "a";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
const pc = new PeerConnection({ iceServers: [] });
const audio = pc.addTrack("0", "audio");
const video = pc.addTrack("1", "video");
if (mode === "a") {
// Sender: generate offer, hand to B via files, get B's answer
const offer = await pc.createOffer();
fs.writeFileSync("/tmp/rtp-offer.sdp", offer);
console.log("[a] offer written", offer.length, "bytes");
// wait for B to write its answer
for (let i = 0; i < 300; i++) {
if (fs.existsSync("/tmp/rtp-answer.sdp")) break;
await sleep(200);
}
const answer = fs.readFileSync("/tmp/rtp-answer.sdp", "utf8");
pc.setRemoteDescription(answer, "answer");
// wait connected
for (let i = 0; i < 50; i++) {
if (pc.state() === "connected") break;
await sleep(100);
}
console.log("[a] state:", pc.state());
if (pc.state() !== "connected") {
console.log("[a] FAILED not connected");
process.exit(1);
}
// Setup packetizer like the gateway does
video.setPacketizer("h264", 5678, 101, 90000, 5, 0, 10);
// Real H264 AnnexB (baseline) — read from file created by ffmpeg
const data = fs.readFileSync("/tmp/rtp-input.h264");
console.log("[a] input h264 bytes:", data.length);
// Split into NAL units by start codes, then group into access units
// the same way Demuxer does (param sets + one slice per frame).
const start3 = Buffer.from([0, 0, 1]);
const start4 = Buffer.from([0, 0, 0, 1]);
const nals = [];
let i = 0;
while (i < data.length) {
let start = -1;
let startLen = 0;
for (let j = i; j < data.length - 3; j++) {
if (data[j] === 0 && data[j + 1] === 0 && data[j + 2] === 1) {
start = j;
startLen = 3;
if (j > 0 && data[j - 1] === 0) {
start = j - 1;
startLen = 4;
}
break;
}
}
if (start === -1) break;
if (start > i) {
nals.push(data.subarray(i, start));
}
i = start + startLen;
}
console.log("[a] NALs:", nals.length);
// Group: buffer param sets, flush on slice (like Demuxer.flushAccessUnit)
let pending = [];
let frameCount = 0;
const flush = () => {
if (pending.length === 0) return;
const parts = pending.map((n) => Buffer.concat([start4, n]));
const au = Buffer.concat(parts);
pending = [];
video.sendFrame(au);
video.addTimestamp(3000); // 30fps @ 90kHz
frameCount++;
};
for (const n of nals) {
const t = n[0] & 0x1f;
if (t === 1 || t === 5) {
flush(); // previous AU closed by this slice
pending.push(n);
} else {
pending.push(n); // param set / SEI
}
}
flush();
console.log("[a] sent frames:", frameCount);
await sleep(3000); // let packets flow
console.log("[a] done");
pc.close();
process.exit(0);
} else {
// Receiver: read offer, answer, keep alive
for (let i = 0; i < 300; i++) {
if (fs.existsSync("/tmp/rtp-offer.sdp")) break;
await sleep(200);
}
const offer = fs.readFileSync("/tmp/rtp-offer.sdp", "utf8");
pc.setRemoteDescription(offer, "offer");
const answer = await pc.createAnswer(offer);
fs.writeFileSync("/tmp/rtp-answer.sdp", answer);
console.log("[b] answer written");
for (let i = 0; i < 50; i++) {
if (pc.state() === "connected") break;
await sleep(100);
}
console.log("[b] state:", pc.state());
await sleep(10000); // hold while sender streams
console.log("[b] done");
pc.close();
process.exit(0);
}
}
main().catch((e) => {
console.error("FAILED:", e.message);
process.exit(1);
});
setTimeout(() => {
console.error("TIMEOUT");
process.exit(1);
}, 30000);
@@ -17,10 +17,22 @@ await new Promise<void>((resolve, reject) => {
const p = spawn(
FFMPEG,
[
"-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=size=640x360:rate=30:duration=2",
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p",
"-f", "h264", clip,
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc=size=640x360:rate=30:duration=2",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-f",
"h264",
clip,
],
{ stdio: ["ignore", "ignore", "pipe"] },
);
@@ -95,7 +107,10 @@ for (const f of frames) {
const hasParams = types.some((t) => t === 7 || t === 8);
const isKey = (f.flags & 1) !== 0;
if (!hasSlice) framesWithoutSlice++;
if (types.length === 1 && (types[0] === 7 || types[0] === 8 || types[0] === 6)) {
if (
types.length === 1 &&
(types[0] === 7 || types[0] === 8 || types[0] === 6)
) {
bareParamSetFrames++;
}
if (isKey && hasParams) keyframesWithParamSets++;
@@ -106,20 +121,28 @@ console.log(
`metadata: ${video.codecName} ${video.width}x${video.height} fps=${video.framerate_num}/${video.framerate_den}`,
);
console.log(`frames while stream OPEN (not ended): ${frames.length}`);
console.log(`frames w/o slice NAL: ${framesWithoutSlice}, bare param-set frames: ${bareParamSetFrames}`);
console.log(`keyframes with SPS/PPS: ${keyframesWithParamSets}, without: ${keyframesWithoutParamSets}`);
console.log(
`frames w/o slice NAL: ${framesWithoutSlice}, bare param-set frames: ${bareParamSetFrames}`,
);
console.log(
`keyframes with SPS/PPS: ${keyframesWithParamSets}, without: ${keyframesWithoutParamSets}`,
);
if (frames.length === 0) {
console.error("FAIL: no frames emitted while input still open (deadlock)");
close();
process.exit(1);
}
if (bareParamSetFrames > 0 || framesWithoutSlice > 0) {
console.error("FAIL: demux emitted bare parameter-set frames (must group into access units)");
console.error(
"FAIL: demux emitted bare parameter-set frames (must group into access units)",
);
close();
process.exit(1);
}
if (frames.some((f) => f.duration !== 1 || f.timeBase.den !== 30)) {
console.error("FAIL: frame duration/timeBase not 1/30 (RTP timestamp advance wrong)");
console.error(
"FAIL: frame duration/timeBase not 1/30 (RTP timestamp advance wrong)",
);
close();
process.exit(1);
}