Phase 2 — replace the 114MB node-av binary with a plain ffmpeg spawn:
Demuxer.ts: spool stream input to temp file → probe via ffmpeg stderr
(ffmpeg-headless ships NO ffprobe — parse 'Stream #0:0: Video: h264...
640x360, 30 fps' from -loglevel info) → ffmpeg -c copy -f h264 pipe:1
→ NAL-split frames. Falls back to h264 defaults when probe fails.
prepareStream.ts: resolve ffmpeg from FFMPEG_PATH env → Nix store
ffmpeg-headless (hash-prefixed entry!) → PATH; split encoder option
strings ('-forced-idr 1' → two argv) — fluent-ffmpeg used to split
automatically, spawn does not.
E2E tests (tsx, need LD_LIBRARY_PATH=/tmp/ldc-build):
- golive-demux-e2e.ts: real H264 file → 33 NAL frames + dims from probe
- golive-pipeline-e2e.ts: prepareStream → demux → 82 frames
- golive-videostream-e2e.ts: local peer pair → demux → VideoStream →
native setPacketizer/sendFrame/addTimestamp → 33 frames sent connected
Pitfalls captured: setPacketizer before negotiation breaks createOffer
('No DataChannel or Track to negotiate'); track methods are read-only
(no monkeypatching); both peers must declare audio+video tracks or
answer hangs; state() returns 'closed' after close() — snapshot first.
79 lines
2.5 KiB
TypeScript
79 lines
2.5 KiB
TypeScript
// Phase 2 E2E: demux → VideoStream → native packetizer chain (local pair).
|
|
// Run: npx tsx tests/golive-videostream-e2e.ts
|
|
|
|
import { createReadStream } from "node:fs";
|
|
import { demux } from "../src/goLive/Demuxer.js";
|
|
import { loadNative } from "../src/goLive/native.js";
|
|
import { VideoStream } from "../src/goLive/VideoStream.js";
|
|
|
|
async function main() {
|
|
const native = loadNative();
|
|
const { PeerConnection } = native;
|
|
|
|
const pcA = new PeerConnection({ iceServers: [] });
|
|
const pcB = new PeerConnection({ iceServers: [] });
|
|
|
|
pcA.onStateChange(() => {});
|
|
pcB.onStateChange(() => {});
|
|
|
|
// Both peers declare audio+video tracks (exact passing test-packetizer
|
|
// pattern — tracks trigger negotiation).
|
|
pcA.addTrack("0", "audio");
|
|
pcA.addTrack("1", "video");
|
|
pcB.addTrack("0", "audio");
|
|
const trackB = pcB.addTrack("1", "video");
|
|
if (!trackB) throw new Error("no track from addTrack");
|
|
|
|
const track = trackB;
|
|
// NOTE: setPacketizer is called AFTER connected (see below) — calling it
|
|
// before negotiation breaks the offer (libdatachannel negotiation state).
|
|
|
|
const offer = await pcA.createOffer();
|
|
console.log("T1 offer");
|
|
pcB.setRemoteDescription(offer, "offer");
|
|
const answer = await pcB.createAnswer(offer);
|
|
console.log("T2 answer");
|
|
pcA.setRemoteDescription(answer, "answer");
|
|
|
|
await new Promise((r) => setTimeout(r, 1500));
|
|
console.log("T3 states:", pcA.state(), "/", pcB.state());
|
|
|
|
// Discord-style SSRC/payload: H264 101 @ 90kHz, playout ext id 5
|
|
track.setPacketizer("h264", 0x1234, 101, 90000, 5, 0, 10);
|
|
|
|
const { video, close } = await demux(createReadStream("/tmp/sample.h264"), {
|
|
format: "h264",
|
|
});
|
|
console.log("video stream:", video.codecName, video.width, "x", video.height);
|
|
|
|
const conn = {
|
|
sendVideoFrame: (frame: Buffer, frametime: number) => {
|
|
track.sendFrame(frame);
|
|
track.addTimestamp(Math.round((frametime * 90000) / 1000));
|
|
},
|
|
} as unknown as { sendVideoFrame(frame: Buffer, frametime: number): void };
|
|
|
|
const vStream = new VideoStream(conn as never);
|
|
let sent = 0;
|
|
const origSend = conn.sendVideoFrame;
|
|
conn.sendVideoFrame = (frame: Buffer, frametime: number) => {
|
|
sent++;
|
|
origSend(frame, frametime);
|
|
};
|
|
|
|
video.stream.pipe(vStream);
|
|
await new Promise((r) => setTimeout(r, 4000));
|
|
|
|
console.log(`sent ${sent} frames via VideoStream; B state=${pcB.state()}`);
|
|
const ok = sent > 0 && pcB.state() === "connected";
|
|
close();
|
|
pcA.close();
|
|
pcB.close();
|
|
process.exit(ok ? 0 : 1);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("E2E failed:", e);
|
|
process.exit(1);
|
|
});
|