fix(goLive): download screen-share media to file before play (not live pipe)
The live pipe (yt-dlp -o - -> ffmpeg) delivers data at network speed with unreliable PTS, which defeats ffmpeg -re and made x264 -r 30 force-duplicate held frames -> ~1fps video (the patah-patah symptom). Per user suggestion, download the FULL clip to a temp file first (downloadScreenInput), then feed that FILE PATH to prepareStream. String inputs already get -re, so the encoder now paces cleanly at 1x against a monotonic-PTS file — proven reliable in local tests (vs the live pipe which always bursted). Temp file is removed on stream end / stop. - getDirectScreenInput -> downloadScreenInput (returns file path) - resolveInputWithRetry now awaits a completed file + retries on failure - screenShareController.stops/cleanup removes the per-run tmpdir - screenShareInput.test.ts updated to the file-download contract
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Screen share input resolution tests
|
||||
//
|
||||
// getDirectScreenInput now streams the merged video+audio media straight from
|
||||
// yt-dlp stdout (`-o -`) — same auth-handling mechanism as resolveMediaUrl for
|
||||
// music. There is no manual URL fetch or local ffmpeg merge anymore.
|
||||
// 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.
|
||||
// yt-dlp is faked via a PATH shim script so the test does not hit the
|
||||
// network or need real binaries.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import {
|
||||
@@ -18,34 +20,41 @@ import {
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { getDirectScreenInput } from "../src/modules/voice-recording/mediaSource.js";
|
||||
import { downloadScreenInput } from "../src/modules/voice-recording/mediaSource.js";
|
||||
|
||||
// ─── fake bin dir ──────────────────────────────────────────────────────────────
|
||||
let fakeBinDir: string | null = null;
|
||||
const realPath = process.env.PATH;
|
||||
|
||||
beforeAll(() => {
|
||||
fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-bins-"));
|
||||
|
||||
// Fake yt-dlp: streams a few bytes to stdout (like `yt-dlp -o -` does).
|
||||
// Modes (env):
|
||||
// GMW_FAKE_YTDLP_FAIL=1 → stderr 403 + exit 8 WITHOUT stdout bytes
|
||||
// (mimics a download rejected by YouTube).
|
||||
const ytShim = `#!/usr/bin/env bash
|
||||
// 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
|
||||
# Fake yt-dlp — ignore args, emit a few bytes so consumers see a live stream.
|
||||
head -c 4096 /dev/urandom
|
||||
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}`;
|
||||
});
|
||||
|
||||
@@ -56,72 +65,47 @@ afterAll(() => {
|
||||
process.env.PATH = realPath;
|
||||
});
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────────
|
||||
function consumeStream(stream: Readable): Promise<string> {
|
||||
return new Promise<string>((resolve) => {
|
||||
let got = 0;
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
got += chunk.length;
|
||||
});
|
||||
stream.on("error", () => resolve(`error-after-${got}B`));
|
||||
stream.on("end", () => resolve(`end-after-${got}B`));
|
||||
stream.resume();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── tests ─────────────────────────────────────────────────────────────────────
|
||||
describe("getDirectScreenInput", () => {
|
||||
it("returns a live Readable and streams media bytes from yt-dlp stdout", async () => {
|
||||
const result = await getDirectScreenInput("https://youtu.be/abc");
|
||||
expect(Readable.isReadable(result)).toBe(true);
|
||||
|
||||
const outcome = await consumeStream(result);
|
||||
// The fake yt-dlp emits 4096 bytes → the stream must deliver them.
|
||||
expect(outcome).toMatch(/^(error|end)-after-[1-9]\d*B$/);
|
||||
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("destroys the stream with an error when yt-dlp fails before producing data (transient 403)", async () => {
|
||||
// Simulate the production failure: yt-dlp's downloader hits a transient
|
||||
// YouTube 403 and exits non-zero WITHOUT emitting a single byte. The
|
||||
// returned Readable must terminate with zero bytes (error OR end) so the
|
||||
// controller's resolveInputWithRetry retries with a fresh run instead of
|
||||
// streaming a silent black tile.
|
||||
it("rejects when yt-dlp fails (transient 403) so the controller retries", async () => {
|
||||
process.env.GMW_FAKE_YTDLP_FAIL = "1";
|
||||
try {
|
||||
const result = await getDirectScreenInput("https://youtu.be/abc");
|
||||
expect(Readable.isReadable(result)).toBe(true);
|
||||
|
||||
const outcome = await consumeStream(result);
|
||||
expect(outcome).toMatch(/^(error|end)-after-0B$/);
|
||||
await expect(downloadScreenInput("https://youtu.be/abc")).rejects.toThrow(
|
||||
/403|exit 8|failed/i,
|
||||
);
|
||||
} finally {
|
||||
delete process.env.GMW_FAKE_YTDLP_FAIL;
|
||||
}
|
||||
});
|
||||
|
||||
it("passes -o - (stdout streaming) and a temp dir to yt-dlp", async () => {
|
||||
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;
|
||||
// Augment the fake to dump its argv.
|
||||
const shim = `#!/usr/bin/env bash
|
||||
printf '%s\\n' "$*" >> "$GMW_FAKE_YTDLP_DUMP_ARGS"
|
||||
head -c 4096 /dev/urandom
|
||||
exit 0
|
||||
`;
|
||||
const realPath2 = process.env.PATH;
|
||||
const dir = fakeBinDir as unknown as string;
|
||||
const existing = join(dir, "yt-dlp");
|
||||
// Overwrite with the argv-dumping variant.
|
||||
writeFileSync(existing, shim);
|
||||
writeFileSync(existing, ytShimDump);
|
||||
chmodSync(existing, 0o755);
|
||||
try {
|
||||
const result = await getDirectScreenInput("https://youtu.be/abc");
|
||||
await consumeStream(result);
|
||||
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).toContain("-o -");
|
||||
expect(args).not.toContain("-o -");
|
||||
expect(args).toContain("-o ");
|
||||
expect(args).toMatch(/gmw-ytdlp-/);
|
||||
} finally {
|
||||
delete process.env.GMW_FAKE_YTDLP_DUMP_ARGS;
|
||||
|
||||
Reference in New Issue
Block a user