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.
This commit is contained in:
1 parent
80e9b913c1
commit
999c054bb2
2 files changed
+138
-12
No files matched your search
@@ -1,5 +1,13 @@
|
||||
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 } from "../src/modules/voice-recording/videoReceiver.js";
|
||||
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 {
|
||||
@@ -60,3 +68,57 @@ describe("H264Depacketizer", () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user