refactor(gateway): remove screen-share / GoLive feature entirely
Drop the Discord Go Live (screen share) stack across the discord-gateway: - delete src/goLive/ (19 modules: Streamer, Demuxer, encoders, WebRTC wrapper, native loader, etc.) - delete native/libdatachannel-min/ N-API binding + flake native build + LD_LIBRARY_PATH wiring - delete screenShareController.ts and screen-share tests (goLive-port, golive-*, demuxerNut, screenShareInput) - mediaSource.ts: remove Invidious helpers + downloadScreenInput (YouTube full-file download) - mediaTypes.ts: drop ScreenShare* types, narrow MediaMode to 'music' and DiscordPlayerOwner to non-screen - media.handler.ts: remove screen branch, screenController/screenPlayback, voice-disconnect/reconnect accessor - commandHandler.ts: stop passing getVoiceStatus / setVoiceController into MediaHandler - media handler now only handles music; music queue/playback/status untouched Verification: tsc --noEmit clean, biome clean on touched files, no lingering goLive/screenShare refs in BE/FE/gateway.
This commit is contained in:
@@ -1,187 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { demux } from "../src/goLive/Demuxer.js";
|
||||
|
||||
/** Same resolution as Demuxer.resolveBin: env override → Nix store scan. */
|
||||
function resolveFfmpeg(): string | null {
|
||||
const override = process.env.FFMPEG_PATH;
|
||||
if (override && existsSync(override)) return override;
|
||||
const store = "/nix/store";
|
||||
if (existsSync(store)) {
|
||||
for (const entry of readdirSync(store)) {
|
||||
if (!entry.includes("ffmpeg-headless-")) continue;
|
||||
const candidate = join(store, entry, "bin", "ffmpeg");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return existsSync("/usr/bin/ffmpeg") ? "/usr/bin/ffmpeg" : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ogg Opus byte stream built by hand: OpusHead (19B) + a few 20ms opus
|
||||
* frames, wrapped in valid Ogg pages (CRC 0 — the parser ignores CRC).
|
||||
*/
|
||||
function buildOggOpusBytes(frames: number): Buffer {
|
||||
const opusHead = Buffer.from([
|
||||
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead"
|
||||
0x01, 0x02, 0x38, 0x01, 0x80, 0xbb, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, // version 1, 2ch, 48000
|
||||
]);
|
||||
// A minimal valid opus data packet: TOC 0xFC (48kHz stereo 20ms) + payload
|
||||
const dataPacket = Buffer.alloc(20, 0);
|
||||
dataPacket[0] = 0xfc;
|
||||
const makePage = (
|
||||
seq: number,
|
||||
headerType: number,
|
||||
serial: number,
|
||||
packets: Buffer[],
|
||||
): Buffer => {
|
||||
const segmentTable: number[] = [];
|
||||
const payloadParts: Buffer[] = [];
|
||||
for (const p of packets) {
|
||||
let remaining = p.length;
|
||||
let off = 0;
|
||||
do {
|
||||
const chunk = Math.min(255, remaining);
|
||||
segmentTable.push(chunk);
|
||||
payloadParts.push(p.subarray(off, off + chunk));
|
||||
off += chunk;
|
||||
remaining -= chunk;
|
||||
} while (remaining > 0);
|
||||
}
|
||||
const payload = Buffer.concat(payloadParts);
|
||||
const header = Buffer.alloc(27 + segmentTable.length);
|
||||
header.write("OggS", 0, "latin1");
|
||||
header[4] = 0; // version
|
||||
header[5] = headerType;
|
||||
header.writeUInt32LE(0, 6); // granule (unused)
|
||||
header.writeUInt32LE(0, 10);
|
||||
header.writeUInt32LE(serial, 14);
|
||||
header.writeUInt32LE(seq, 18);
|
||||
header.writeUInt32LE(0, 22); // crc (ignored)
|
||||
header[26] = segmentTable.length;
|
||||
for (let i = 0; i < segmentTable.length; i++) header[27 + i] = segmentTable[i];
|
||||
return Buffer.concat([header, payload]);
|
||||
};
|
||||
const pages: Buffer[] = [];
|
||||
const serial = 0x1234;
|
||||
let seq = 0;
|
||||
// Page 0: BOS + OpusHead (19 bytes, single lacing)
|
||||
pages.push(makePage(seq++, 0x02, serial, [opusHead]));
|
||||
// Pages 1+: data packets, a few per page
|
||||
const perPage = 3;
|
||||
for (let i = 0; i < frames; i += perPage) {
|
||||
const pkts = [];
|
||||
for (let j = 0; j < perPage && i + j < frames; j++) pkts.push(dataPacket);
|
||||
pages.push(makePage(seq++, 0x00, serial, pkts));
|
||||
}
|
||||
return Buffer.concat(pages);
|
||||
}
|
||||
|
||||
describe("Demuxer NUT path with audio", () => {
|
||||
it("emits video access units AND parsed opus audio frames", async () => {
|
||||
// Real NUT file (video h264 + opus) produced by ffmpeg — generated once
|
||||
// in this test via ffmpeg, skipped if ffmpeg is unavailable.
|
||||
const ffmpeg = resolveFfmpeg();
|
||||
if (!ffmpeg) {
|
||||
console.warn("ffmpeg not found — skipping NUT integration case");
|
||||
return;
|
||||
}
|
||||
const dir = mkdtempSync(join(tmpdir(), "gmw-nut-test-"));
|
||||
const inWebm = join(dir, "in.webm");
|
||||
const inNut = join(dir, "in.nut");
|
||||
try {
|
||||
// Build a tiny webm (vpx + opus) then remux to NUT h264+opus — mirrors
|
||||
// prepareStream(includeAudio) output.
|
||||
const { spawnSync } = await import("node:child_process");
|
||||
const gen = spawnSync(
|
||||
ffmpeg,
|
||||
[
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=3",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=3",
|
||||
"-c:v", "libvpx-vp9", "-b:v", "100k", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "libopus", "-b:a", "48k", "-f", "webm", "-y", inWebm,
|
||||
],
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
if (gen.status !== 0) {
|
||||
console.warn("ffmpeg webm gen failed — skipping", gen.stderr?.toString().slice(0, 200));
|
||||
return;
|
||||
}
|
||||
const enc = spawnSync(
|
||||
ffmpeg,
|
||||
[
|
||||
"-hide_banner", "-loglevel", "error", "-i", inWebm,
|
||||
"-map", "0:v:0", "-c:v", "libx264", "-profile:v", "baseline",
|
||||
"-x264-params", "repeat-headers=1", "-preset", "superfast",
|
||||
"-pix_fmt", "yuv420p", "-g", "10", "-forced-idr", "1",
|
||||
"-map", "0:a:0?", "-c:a", "libopus", "-b:a", "48k", "-ar", "48000", "-ac", "2",
|
||||
"-f", "nut", "-y", inNut,
|
||||
],
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
if (enc.status !== 0) {
|
||||
console.warn("ffmpeg nut gen failed — skipping", enc.stderr?.toString().slice(0, 200));
|
||||
return;
|
||||
}
|
||||
const { readFileSync } = await import("node:fs");
|
||||
// demux() takes string | PassThrough — wrap the file read. Drip the
|
||||
// bytes in quickly (well within demux's 1.5s metadata window) so ffmpeg
|
||||
// prints both stream lines before demux() resolves.
|
||||
const input = new PassThrough();
|
||||
const nutBytes = readFileSync(inNut);
|
||||
const CHUNK = Math.max(1024, Math.floor(nutBytes.length / 20));
|
||||
let off = 0;
|
||||
const drip = setInterval(() => {
|
||||
if (off >= nutBytes.length) {
|
||||
clearInterval(drip);
|
||||
input.end();
|
||||
return;
|
||||
}
|
||||
input.write(nutBytes.subarray(off, off + CHUNK));
|
||||
off += CHUNK;
|
||||
}, 10);
|
||||
const { video, audio, close } = await demux(input, {
|
||||
format: "nut",
|
||||
frameRate: 10,
|
||||
});
|
||||
const vFrames: number[] = [];
|
||||
const aFrames: number[] = [];
|
||||
video?.stream.on("data", (f: { data: Buffer | null; flags: number }) => {
|
||||
if (f.data) vFrames.push(f.data.length);
|
||||
});
|
||||
audio?.stream.on("data", (f: { data: Buffer | null; duration: number }) => {
|
||||
if (f.data) aFrames.push(f.duration);
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
video?.stream.on("end", resolve);
|
||||
setTimeout(resolve, 6000);
|
||||
});
|
||||
close();
|
||||
expect(vFrames.length).toBeGreaterThan(0);
|
||||
// ~10fps × 1s of video → at least 5 access units
|
||||
expect(vFrames.length).toBeGreaterThanOrEqual(5);
|
||||
// ~50 opus frames/sec of audio
|
||||
expect(aFrames.length).toBeGreaterThan(10);
|
||||
// opus frames are 20ms (duration 960 @ 48kHz)
|
||||
expect(aFrames[0]).toBe(960);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("parses a hand-built Ogg Opus stream into frames", async () => {
|
||||
// Feed the OGG bytes via the demux's audio path is not directly
|
||||
// exposed — instead verify the parser contract through the NUT path is
|
||||
// covered above; here we sanity-check the byte layout our parser reads.
|
||||
const bytes = buildOggOpusBytes(7);
|
||||
expect(bytes.subarray(0, 4).toString("latin1")).toBe("OggS");
|
||||
// 7 frames + header across pages
|
||||
expect(bytes.includes(Buffer.from("OpusHead"))).toBe(true);
|
||||
expect(bytes.subarray(28, 36).toString("latin1")).toBe("OpusHead");
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* goLive port smoke tests — verify the TS layer (no native binding needed
|
||||
* for these; native is covered by the C++/node test-packetizer.js).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { H264Helpers } from "../src/goLive/AnnexBHelper.js";
|
||||
import { AVCodecID } from "../src/goLive/Demuxer.js";
|
||||
import {
|
||||
BaseMediaStream,
|
||||
CodecPayloadType,
|
||||
Encoders,
|
||||
normalizeVideoCodec,
|
||||
} from "../src/goLive/index.js";
|
||||
import { rewriteSPSVUI } from "../src/goLive/SPSVUIRewriter.js";
|
||||
|
||||
describe("goLive port: codec + encoders", () => {
|
||||
it("normalizeVideoCodec maps aliases to canonical names", () => {
|
||||
expect(normalizeVideoCodec("H.264")).toBe("H264");
|
||||
expect(normalizeVideoCodec("AVC")).toBe("H264");
|
||||
expect(normalizeVideoCodec("h265")).toBe("H265");
|
||||
expect(normalizeVideoCodec("vp8")).toBe("VP8");
|
||||
expect(normalizeVideoCodec("av1")).toBe("AV1");
|
||||
});
|
||||
|
||||
it("software encoder exposes x264 libx264 baseline zerolatency", () => {
|
||||
const enc = Encoders.software()();
|
||||
expect(enc.H264.name).toBe("libx264");
|
||||
expect(enc.H264.options).toContain("-preset superfast");
|
||||
expect(enc.H264.options).toContain("-tune zerolatency");
|
||||
// Baseline profile is REQUIRED to match the SDP's profile-level-id=42e01f
|
||||
// (constrained baseline) — High-profile bitstreams fail to decode → black
|
||||
expect(enc.H264.options).toContain("-profile:v baseline");
|
||||
});
|
||||
|
||||
it("CodecPayloadType has opus + H264 entries", () => {
|
||||
expect(CodecPayloadType.opus).toBeDefined();
|
||||
expect(CodecPayloadType.H264).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("goLive port: annexb + sps rewriter", () => {
|
||||
it("H264Helpers detects NAL unit types", () => {
|
||||
const nal = Buffer.from([0x67, 0x42, 0x00, 0x1e]); // SPS
|
||||
expect(H264Helpers.getUnitType(nal)).toBe(7); // SPS type
|
||||
expect(H264Helpers.getUnitType(Buffer.from([0x65, 0x88]))).toBe(5); // IDR
|
||||
});
|
||||
|
||||
it("rewriteSPSVUI returns a buffer for valid SPS", () => {
|
||||
const sps = Buffer.from([
|
||||
0x67, 0x42, 0x00, 0x1e, 0x96, 0x54, 0x05, 0x01, 0xec, 0x80,
|
||||
]);
|
||||
expect(() => rewriteSPSVUI(sps)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("goLive port: streams", () => {
|
||||
it("BaseMediaStream accepts plain frame objects", () => {
|
||||
// BaseMediaStream is abstract — use a concrete subclass that no-ops the
|
||||
// packetizer hook.
|
||||
class TestStream extends BaseMediaStream {
|
||||
async _sendFrame(_frame: Buffer, _frametime: number): Promise<void> {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
const stream = new TestStream("video");
|
||||
const frame = {
|
||||
data: Buffer.from([1, 2, 3]),
|
||||
pts: 0,
|
||||
duration: 40,
|
||||
timeBase: { num: 1, den: 48000 },
|
||||
flags: 0,
|
||||
streamIndex: 0,
|
||||
free: () => {},
|
||||
};
|
||||
expect(() => stream.write(frame)).not.toThrow();
|
||||
stream.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe("goLive port: demuxer codec ids", () => {
|
||||
it("maps H264/HEVC/opus AVCodecID values", () => {
|
||||
expect(AVCodecID.AV_CODEC_ID_H264).toBe(27);
|
||||
expect(AVCodecID.AV_CODEC_ID_HEVC).toBe(173);
|
||||
expect(AVCodecID.AV_CODEC_ID_OPUS).toBe(86019);
|
||||
});
|
||||
});
|
||||
|
||||
describe("goLive port: prepareStream option merge", () => {
|
||||
it("merges default options into the descriptor (no ffmpeg spawn)", () => {
|
||||
// Import the merge logic directly via the module; prepareStream spawns
|
||||
// ffmpeg so we verify the descriptors it would build by checking the
|
||||
// encoder + option functions that prepareStream uses.
|
||||
const enc = Encoders.software()();
|
||||
expect(enc.H264.options).toContain("-forced-idr 1");
|
||||
expect(normalizeVideoCodec("H264")).toBe("H264");
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
// Phase 2 E2E: Demuxer on a real ffmpeg-generated H264 file.
|
||||
// Run: npx tsx tests/golive-demux-e2e.ts
|
||||
|
||||
import { createReadStream } from "node:fs";
|
||||
import { demux } from "../src/goLive/Demuxer.js";
|
||||
|
||||
const input = process.argv[2] ?? "/tmp/sample.h264";
|
||||
const { video, close } = await demux(createReadStream(input), {
|
||||
format: "h264",
|
||||
});
|
||||
|
||||
console.log(
|
||||
"video:",
|
||||
JSON.stringify({
|
||||
codecName: video.codecName,
|
||||
width: video.width,
|
||||
height: video.height,
|
||||
duration: video.duration,
|
||||
fps: Math.round(video.framerate_num / video.framerate_den),
|
||||
}),
|
||||
);
|
||||
|
||||
let count = 0;
|
||||
let keyframes = 0;
|
||||
let bytes = 0;
|
||||
video.stream.on("data", (frame: { data: Buffer; keyframe: boolean }) => {
|
||||
count++;
|
||||
bytes += frame.data.length;
|
||||
if (frame.keyframe) keyframes++;
|
||||
});
|
||||
video.stream.on("end", () => {
|
||||
console.log(`frames: ${count} (${keyframes} keyframes), ${bytes} bytes`);
|
||||
close();
|
||||
process.exit(0);
|
||||
});
|
||||
video.stream.on("error", (e: unknown) => {
|
||||
console.error("stream error:", e);
|
||||
close();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
// Regression test: demux must emit frames from a LIVE stream that never
|
||||
// ends (the NUT/H264 merge output during playback). The old implementation
|
||||
// spooled the whole stream to a file first → deadlocked forever → 0 frames.
|
||||
// v2: also validates ACCESS-UNIT grouping — each emitted frame must be a
|
||||
// complete picture (parameter sets + slice), never a bare SPS/PPS/SEI NAL,
|
||||
// and must be timestamped at the video frame rate (RTP +clockRate/fps).
|
||||
// Run: npx tsx tests/golive-demux-live-e2e.ts [ffmpeg-path]
|
||||
import { spawn } from "node:child_process";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { demux } from "../src/goLive/Demuxer.js";
|
||||
|
||||
const FFMPEG = process.argv[2] ?? "ffmpeg";
|
||||
|
||||
// 1) Generate a 2s H264 test clip to a temp file
|
||||
const clip = "/tmp/golive-live-test.h264";
|
||||
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,
|
||||
],
|
||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||
);
|
||||
let err = "";
|
||||
p.stderr?.on("data", (d: Buffer) => (err += d.toString()));
|
||||
p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(err))));
|
||||
});
|
||||
|
||||
// 2) Feed the clip through a PassThrough but DON'T end it (live semantics),
|
||||
// with a small pause after the first chunk so demux has time to emit.
|
||||
const input = new PassThrough();
|
||||
const demuxPromise = demux(input, { format: "h264", frameRate: 30 });
|
||||
const { video, close } = await demuxPromise;
|
||||
if (!video) {
|
||||
console.error("FAIL: demux returned no video stream");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface Emitted {
|
||||
data: Buffer;
|
||||
duration: number;
|
||||
timeBase: { num: number; den: number };
|
||||
flags: number;
|
||||
}
|
||||
const frames: Emitted[] = [];
|
||||
video.stream.on("data", (frame: Emitted) => {
|
||||
frames.push(frame);
|
||||
});
|
||||
|
||||
const fs = await import("node:fs");
|
||||
const buf = fs.readFileSync(clip);
|
||||
const chunkSize = 16384;
|
||||
for (let i = 0; i < buf.length; i += chunkSize) {
|
||||
input.write(buf.subarray(i, i + chunkSize));
|
||||
if (i === 0) await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
// Stream still open — if the old spool logic was here we'd never emit.
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// 3) Validate access-unit structure
|
||||
const nalTypes = (frame: Buffer): number[] => {
|
||||
const out: number[] = [];
|
||||
let i = 0;
|
||||
while (i < frame.length - 3) {
|
||||
if (frame[i] === 0 && frame[i + 1] === 0 && frame[i + 2] === 1) {
|
||||
const start = i;
|
||||
let j = i + 3;
|
||||
if (frame[j - 4] === 0 && j >= 4) {
|
||||
// 4-byte start code already consumed by i pointing at the 3-byte tail
|
||||
}
|
||||
while (j < frame.length - 3) {
|
||||
if (frame[j] === 0 && frame[j + 1] === 0 && frame[j + 2] === 1) break;
|
||||
j++;
|
||||
}
|
||||
const nal = frame.subarray(start + 3, j);
|
||||
if (nal.length > 0) out.push(nal[0] & 0x1f);
|
||||
i = j;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
let bareParamSetFrames = 0;
|
||||
let framesWithoutSlice = 0;
|
||||
let keyframesWithParamSets = 0;
|
||||
let keyframesWithoutParamSets = 0;
|
||||
for (const f of frames) {
|
||||
const types = nalTypes(f.data);
|
||||
const hasSlice = types.some((t) => t === 1 || t === 5);
|
||||
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)
|
||||
) {
|
||||
bareParamSetFrames++;
|
||||
}
|
||||
if (isKey && hasParams) keyframesWithParamSets++;
|
||||
if (isKey && !hasParams) keyframesWithoutParamSets++;
|
||||
}
|
||||
|
||||
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}`,
|
||||
);
|
||||
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)",
|
||||
);
|
||||
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)",
|
||||
);
|
||||
close();
|
||||
process.exit(1);
|
||||
}
|
||||
input.end();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
close();
|
||||
console.log("PASS: live stream demux works + access units grouped correctly");
|
||||
process.exit(0);
|
||||
@@ -1,58 +0,0 @@
|
||||
// Phase 2 E2E: full pipeline prepareStream → demux → frame stream.
|
||||
// Run: npx tsx tests/golive-pipeline-e2e.ts
|
||||
|
||||
import { demux } from "../src/goLive/Demuxer.js";
|
||||
import { Encoders } from "../src/goLive/Encoders.js";
|
||||
import { prepareStream } from "../src/goLive/prepareStream.js";
|
||||
import { normalizeVideoCodec } from "../src/goLive/utils.js";
|
||||
|
||||
// Use a real ffmpeg-generated video file as input (from sample generation).
|
||||
const input = process.argv[2] ?? "/tmp/sample.h264";
|
||||
|
||||
const prepared = prepareStream(input, {
|
||||
encoder: Encoders.software({ x264: { preset: "superfast" } }),
|
||||
width: 640,
|
||||
height: 360,
|
||||
frameRate: 25,
|
||||
bitrateVideo: 500,
|
||||
bitrateVideoMax: 800,
|
||||
includeAudio: false,
|
||||
videoCodec: normalizeVideoCodec("H264"),
|
||||
});
|
||||
|
||||
console.log(
|
||||
"prepareStream ok, videoCodec:",
|
||||
prepared.videoCodec,
|
||||
"size:",
|
||||
prepared.width,
|
||||
"x",
|
||||
prepared.height,
|
||||
);
|
||||
|
||||
const { video, close } = await demux(prepared.output, { format: "h264" });
|
||||
console.log("demux video:", video?.codecName, video?.width, "x", video?.height);
|
||||
|
||||
let frames = 0;
|
||||
let keyframes = 0;
|
||||
video.stream.on("data", (f: { keyframe?: boolean }) => {
|
||||
frames++;
|
||||
if (f.keyframe) keyframes++;
|
||||
});
|
||||
video.stream.on("end", () => {
|
||||
console.log(`pipeline frames: ${frames} (${keyframes} keyframes)`);
|
||||
close();
|
||||
prepared.command.kill("SIGTERM");
|
||||
process.exit(frames > 0 ? 0 : 1);
|
||||
});
|
||||
video.stream.on("error", (e: unknown) => {
|
||||
console.error("pipeline error:", e);
|
||||
close();
|
||||
prepared.command.kill("SIGTERM");
|
||||
process.exit(1);
|
||||
});
|
||||
setTimeout(() => {
|
||||
console.log("timeout after 30s — killing");
|
||||
close();
|
||||
prepared.command.kill("SIGTERM");
|
||||
process.exit(2);
|
||||
}, 30000);
|
||||
@@ -1,78 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Screen share input resolution tests
|
||||
//
|
||||
// downloadScreenInput downloads the FULL merged video+audio media to a temp
|
||||
// file first (yt-dlp `-o <tmpdir>/media.%(ext)s`), then returns the file
|
||||
// path. Feeding a FILE path (not a live stdout pipe) to prepareStream is
|
||||
// what makes ffmpeg `-re` pacing reliable — a pipe has unreliable PTS and
|
||||
// caused the ~1fps force-duplication symptom.
|
||||
//
|
||||
// yt-dlp is faked via a PATH shim script so the test does not hit the
|
||||
// network or need real binaries.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { downloadScreenInput } from "../src/modules/voice-recording/mediaSource.js";
|
||||
|
||||
// ─── fake bin dir ──────────────────────────────────────────────────────────────
|
||||
let fakeBinDir: string | null = null;
|
||||
const realPath = process.env.PATH;
|
||||
|
||||
// Bash shim: find the -o pattern, substitute the extension, write 4096 bytes.
|
||||
// Escaped as a separate string to keep the TS template literal simple.
|
||||
const ytShimBody = `prev=""
|
||||
out=""
|
||||
for a in "$@"; do
|
||||
if [ "$prev" = "-o" ]; then out="$a"; fi
|
||||
prev="$a"
|
||||
done
|
||||
if [ "$GMW_FAKE_YTDLP_FAIL" = "1" ]; then
|
||||
echo "ERROR: [youtube] ...: 403 Forbidden (access denied)" >&2
|
||||
exit 8
|
||||
fi
|
||||
target=$(printf '%s' "$out" | sed 's/%(ext)s/.mp4/')
|
||||
head -c 4096 /dev/urandom > "$target"
|
||||
exit 0
|
||||
`;
|
||||
const ytShim = `#!/usr/bin/env bash\n${ytShimBody}`;
|
||||
|
||||
const ytShimDump = `#!/usr/bin/env bash
|
||||
printf '%s\\n' "$*" >> "$GMW_FAKE_YTDLP_DUMP_ARGS"
|
||||
${ytShimBody}
|
||||
`;
|
||||
|
||||
beforeAll(() => {
|
||||
fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-bins-"));
|
||||
writeFileSync(join(fakeBinDir, "yt-dlp"), ytShim);
|
||||
chmodSync(join(fakeBinDir, "yt-dlp"), 0o755);
|
||||
process.env.PATH = `${fakeBinDir}:${process.env.PATH}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (fakeBinDir) {
|
||||
rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
}
|
||||
process.env.PATH = realPath;
|
||||
});
|
||||
|
||||
// ─── tests ─────────────────────────────────────────────────────────────────────
|
||||
describe("downloadScreenInput", () => {
|
||||
it("downloads media to a temp file and returns its path", async () => {
|
||||
const mediaPath = await downloadScreenInput("https://youtu.be/abc");
|
||||
expect(typeof mediaPath).toBe("string");
|
||||
expect(mediaPath).toMatch(/gmw-ytdlp-/);
|
||||
const size = readFileSync(mediaPath).length;
|
||||
expect(size).toBeGreaterThan(0);
|
||||
// The fake yt-dlp writes 4096 bytes → the file must deliver them.
|
||||
expect(size).toBe(4096);
|
||||
});
|
||||
|
||||
it("rejects when yt-dlp fails (transient 403) so the controller retries", async () => {
|
||||
process.env.GMW_FAKE_YTDLP_FAIL = "1";
|
||||
try {
|
||||
await expect(downloadScreenInput("https://youtu.be/abc")).rejects.toThrow(
|
||||
/403|exit 8|failed/i,
|
||||
);
|
||||
} finally {
|
||||
delete process.env.GMW_FAKE_YTDLP_FAIL;
|
||||
}
|
||||
});
|
||||
|
||||
it("passes a file -o pattern (NOT `-o -`) to yt-dlp", async () => {
|
||||
const argsDump = join(
|
||||
tmpdir(),
|
||||
`gmw-ytargs-${process.pid}-${Date.now()}.txt`,
|
||||
);
|
||||
process.env.GMW_FAKE_YTDLP_DUMP_ARGS = argsDump;
|
||||
const realPath2 = process.env.PATH;
|
||||
const dir = fakeBinDir as unknown as string;
|
||||
const existing = join(dir, "yt-dlp");
|
||||
writeFileSync(existing, ytShimDump);
|
||||
chmodSync(existing, 0o755);
|
||||
try {
|
||||
const mediaPath = await downloadScreenInput("https://youtu.be/abc");
|
||||
expect(readFileSync(mediaPath).length).toBeGreaterThan(0);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const args = readFileSync(argsDump, "utf8").trim();
|
||||
expect(args).not.toContain("-o -");
|
||||
expect(args).toContain("-o ");
|
||||
expect(args).toMatch(/gmw-ytdlp-/);
|
||||
} finally {
|
||||
delete process.env.GMW_FAKE_YTDLP_DUMP_ARGS;
|
||||
rmSync(argsDump, { force: true });
|
||||
process.env.PATH = realPath2;
|
||||
}
|
||||
});
|
||||
|
||||
it("copies the on-disk cookies to a temp file instead of handing yt-dlp the original path", async () => {
|
||||
// Regression: recent yt-dlp rewrites `--cookies` file on close. If we hand
|
||||
// it the original system file (root-owned, not writable by the service
|
||||
// user), save-back throws PermissionError → exit 1 → screen share fails.
|
||||
// The copy lives in tmpdir where the service user owns it.
|
||||
const cookieDir = mkdtempSync(join(tmpdir(), "gmw-fake-cookies-"));
|
||||
const cookiePath = join(cookieDir, "ytcookies.txt");
|
||||
writeFileSync(
|
||||
cookiePath,
|
||||
"# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tLOGIN_INFO\tabc123\n",
|
||||
);
|
||||
const argsDump = join(
|
||||
tmpdir(),
|
||||
`gmw-ytargs-${process.pid}-${Date.now()}.txt`,
|
||||
);
|
||||
process.env.GMW_FAKE_YTDLP_DUMP_ARGS = argsDump;
|
||||
process.env.GMW_YT_COOKIES_PATH = cookiePath;
|
||||
const realPath2 = process.env.PATH;
|
||||
const dir = fakeBinDir as unknown as string;
|
||||
const existing = join(dir, "yt-dlp");
|
||||
writeFileSync(existing, ytShimDump);
|
||||
chmodSync(existing, 0o755);
|
||||
try {
|
||||
await downloadScreenInput("https://youtu.be/abc");
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const args = readFileSync(argsDump, "utf8").trim();
|
||||
expect(args).toContain("--cookies");
|
||||
const cookieArg = args
|
||||
.split(/\s+/)
|
||||
.at(args.split(/\s+/).indexOf("--cookies") + 1);
|
||||
expect(cookieArg).toBeDefined();
|
||||
expect(cookieArg).not.toBe(cookiePath); // never the original system file
|
||||
expect(cookieArg).toMatch(/gmw-ytcookies\.\d+\.txt/); // per-process temp copy
|
||||
expect(cookieArg).not.toMatch(/^\/etc\//);
|
||||
} finally {
|
||||
delete process.env.GMW_FAKE_YTDLP_DUMP_ARGS;
|
||||
delete process.env.GMW_YT_COOKIES_PATH;
|
||||
rmSync(argsDump, { force: true });
|
||||
rmSync(cookieDir, { recursive: true, force: true });
|
||||
process.env.PATH = realPath2;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user