feat(rtp-capture): add two-peer RTP capture test for H264 frame transmission
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user