Files
GMW/services/discord-gateway/tests/videoReceiver.test.ts
T
asepharyana 999c054bb2 feat(gateway): auto-mux recorded video to playable MP4 (Phase B)
Phase A captured raw .h264 streams but left them as non-playable elementary
streams. Phase B adds automatic muxing: when a video burst closes, the raw
.h264 is remuxed to a self-contained MP4 via `ffmpeg -c copy` (no re-encode,
fast) with `+faststart`, waits for the write stream to fully flush first so the
mux never reads a truncated tail, and deletes the raw .h264 on success (keeping
it on failure). Output: <RECORDINGS_DIR>/<uid>/video-<ssrc>-<ts>.mp4.

muxToMp4 is exported + covered by a real-ffmpeg vitest (tests/videoReceiver.test.ts):
generates a tiny baseline h264, remuxes, asserts mp4 exists/non-empty & raw deleted
(also the 5 depacketizer tests). Full gateway suite 170/170 green, tsc + biome clean.
2026-08-30 21:49:40 +07:00

125 lines
4.0 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { access, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
H264Depacketizer,
muxToMp4,
} 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);
});
});
describe("muxToMp4", () => {
it("remuxes a raw h264 file into a playable mp4 and deletes the raw file", async () => {
// Skip if ffmpeg is unavailable (headless Nix-less CI).
let ffmpegOk = true;
try {
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
} catch {
ffmpegOk = false;
}
if (!ffmpegOk) {
return;
}
const dir = mkdtempSync(path.join(tmpdir(), "gmw-video-"));
const raw = path.join(dir, "clip.h264");
try {
execFileSync(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc=duration=1:size=320x240:rate=10",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-profile:v",
"baseline",
"-pix_fmt",
"yuv420p",
"-f",
"h264",
"-y",
raw,
],
{ stdio: "ignore" },
);
const mp4 = await muxToMp4(raw);
expect(mp4).toBe(path.join(dir, "clip.mp4"));
// raw file deleted, mp4 exists and is non-empty
await expect(access(raw)).rejects.toThrow();
const s = await stat(mp4);
expect(s.size).toBeGreaterThan(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});