mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(hyperframes-media): resolve python3 -> python/py on Windows (#1922)
The audio engine shells out to `python3` for ElevenLabs TTS
(tts.mjs) and the local Lyria/MusicGen BGM paths (bgm.mjs). `python3`
is correct on macOS/Linux, but a standard python.org install on
Windows only creates `python.exe` plus the `py` launcher -- there is
no `python3.exe` (only the Microsoft Store build adds one). So every
`spawn("python3", ...)`/`spawnSync("python3", ...)` ENOENTs on a normal
Windows Python setup, silently disabling all Python-backed audio
features until the user hand-creates a `python3.exe` shim (reported: a
user copied python.exe to python3.exe to work around it, and separately
another had to target a python3 stub specifically).
Fix: a shared lib/python.mjs resolver probes the platform's candidates
in order and returns the argv prefix that actually launches Python 3 --
`["python3"]` / `["python"]` / `["py", "-3"]` on win32, `["python3"]`
then `["python"]` elsewhere -- resolved once per process. All direct
`python3` spawn sites in tts.mjs (elevenlabs probe + synth) and bgm.mjs
(pyOk probe, Lyria recipe, MusicGen script) now route through it. On
macOS/Linux `python3` still wins first, so behavior there is unchanged;
if nothing probes OK the resolver falls back to `python3` so the spawn
fails loudly exactly as before, never worse.
Scope: only the direct python3 invocations. bgm.mjs's pipInstall()
still shells `pip` -- switching that to `<python> -m pip` is the
separate concern of the open PR #1894 (draft); noting the overlap so
the two don't collide. Windows-specific whisper.cpp-vs-openai-whisper
detection and the npm_execpath/npx spawn issue from the same report are
distinct root causes, not addressed here.
Test: python.test.mjs (node:test) covers every platform/probe branch
with an injected probe -- no real interpreter spawned: non-win32 picks
python3; win32 prefers python3, falls back to python, then to `py -3`;
the py launcher is probed as `py -3 --version`; nothing-runs falls back
to the canonical python3; and pythonInvocation keeps the launcher's -3
ahead of caller args. Existing tts.spawn.test.mjs still passes (6/6).
This commit is contained in:
@@ -15,6 +15,7 @@ import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, openSync, closeSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { downloadTo, searchSounds } from "./heygen.mjs";
|
||||
import { pythonInvocation } from "./python.mjs";
|
||||
|
||||
const r3 = (x) => Number(x.toFixed(3));
|
||||
const lyriaKey = () => process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
|
||||
@@ -26,7 +27,8 @@ const LYRIA_PY_DEPS = ["google-genai", "python-dotenv"];
|
||||
const LYRIA_PY_PROBE = "import google.genai";
|
||||
|
||||
function pyOk(probe) {
|
||||
return spawnSync("python3", ["-c", probe], { stdio: "ignore" }).status === 0;
|
||||
const { cmd, args } = pythonInvocation(["-c", probe]);
|
||||
return spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
|
||||
}
|
||||
function pipInstall(deps) {
|
||||
return spawnSync("pip", ["install", "-q", ...deps], { stdio: "ignore" }).status === 0;
|
||||
@@ -120,11 +122,16 @@ export function generateBgmDetached({
|
||||
|
||||
const fd = openSync(log, "w");
|
||||
if (useLyria) {
|
||||
const proc = spawn(
|
||||
"python3",
|
||||
[lyriaRecipe, "--output", abs, "--duration", String(targetS), "--prompt", prompt],
|
||||
{ detached: true, stdio: ["ignore", fd, fd] },
|
||||
);
|
||||
const { cmd, args } = pythonInvocation([
|
||||
lyriaRecipe,
|
||||
"--output",
|
||||
abs,
|
||||
"--duration",
|
||||
String(targetS),
|
||||
"--prompt",
|
||||
prompt,
|
||||
]);
|
||||
const proc = spawn(cmd, args, { detached: true, stdio: ["ignore", fd, fd] });
|
||||
proc.unref();
|
||||
closeSync(fd);
|
||||
return {
|
||||
@@ -141,7 +148,8 @@ export function generateBgmDetached({
|
||||
const seedS = Math.min(Math.max(seedSeconds, 10), 30);
|
||||
const loops = targetS > seedS ? Math.ceil(targetS / seedS) : 1;
|
||||
const script = musicgenScript({ prompt, abs, targetS, seedS });
|
||||
const proc = spawn("python3", ["-c", script], { detached: true, stdio: ["ignore", fd, fd] });
|
||||
const { cmd, args } = pythonInvocation(["-c", script]);
|
||||
const proc = spawn(cmd, args, { detached: true, stdio: ["ignore", fd, fd] });
|
||||
proc.unref();
|
||||
closeSync(fd);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// python.mjs — resolve which Python 3 executable to spawn, per platform.
|
||||
//
|
||||
// The audio engine (tts.mjs, bgm.mjs) shells out to `python3` for ElevenLabs
|
||||
// TTS and the local Lyria/MusicGen BGM paths. `python3` is the right name on
|
||||
// macOS/Linux, but on Windows the python.org installer only creates
|
||||
// `python.exe` plus the `py` launcher — there is no `python3.exe` (only the
|
||||
// Microsoft Store build adds one). So a bare `spawn("python3", …)` ENOENTs on a
|
||||
// standard Windows Python install, silently disabling every Python-backed audio
|
||||
// feature until the user hand-creates a `python3.exe` shim (reported twice).
|
||||
//
|
||||
// Resolve once, per process: probe the platform's candidates in order and take
|
||||
// the first that actually runs. `py` is the launcher, so it needs a `-3` arg to
|
||||
// select Python 3 — hence candidates are argv PREFIXES, not bare names.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
function defaultProbe(cmd, args) {
|
||||
try {
|
||||
return spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the argv prefix that launches Python 3 on this platform.
|
||||
* Returns e.g. `["python3"]`, `["python"]`, or `["py", "-3"]`.
|
||||
*
|
||||
* Pure except for `probe` (which runs `<cmd> … --version`); both `platform`
|
||||
* and `probe` are injectable so every branch is unit-testable without spawning.
|
||||
* If nothing probes OK, falls back to the canonical name for the platform so
|
||||
* the eventual spawn fails loudly exactly as it did before — never worse.
|
||||
*/
|
||||
export function resolvePythonCommand(platform = process.platform, probe = defaultProbe) {
|
||||
const candidates =
|
||||
platform === "win32" ? [["python3"], ["python"], ["py", "-3"]] : [["python3"], ["python"]];
|
||||
for (const prefix of candidates) {
|
||||
if (probe(prefix[0], [...prefix.slice(1), "--version"])) return prefix;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
let cached = null;
|
||||
|
||||
/** Cached `resolvePythonCommand()` — probing spawns, so resolve at most once. */
|
||||
export function pythonCommand() {
|
||||
if (!cached) cached = resolvePythonCommand();
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `{ cmd, args }` for running Python 3 with `extraArgs`, using the
|
||||
* resolved (or supplied) prefix. Keeps the launcher's `-3` (and any future
|
||||
* prefix args) ahead of the caller's own arguments.
|
||||
*/
|
||||
export function pythonInvocation(extraArgs, prefix = pythonCommand()) {
|
||||
return { cmd: prefix[0], args: [...prefix.slice(1), ...extraArgs] };
|
||||
}
|
||||
|
||||
/** Test-only: clear the cached resolution so a test can re-probe. */
|
||||
export function _resetPythonCommandCacheForTests() {
|
||||
cached = null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolvePythonCommand, pythonInvocation } from "./python.mjs";
|
||||
|
||||
// Regression: on Windows a standard python.org install has no `python3.exe`
|
||||
// (only `python.exe` + the `py` launcher), so `spawn("python3", …)` ENOENTs and
|
||||
// every Python-backed audio feature silently no-ops. resolvePythonCommand takes
|
||||
// injectable platform/probe params so all branches are testable without
|
||||
// spawning a real interpreter.
|
||||
|
||||
// probeFor(names): a probe that reports success only for the given argv-0 names.
|
||||
function probeFor(...names) {
|
||||
const ok = new Set(names);
|
||||
return (cmd) => ok.has(cmd);
|
||||
}
|
||||
|
||||
test("non-win32 uses python3 when it runs", () => {
|
||||
assert.deepEqual(resolvePythonCommand("linux", probeFor("python3")), ["python3"]);
|
||||
assert.deepEqual(resolvePythonCommand("darwin", probeFor("python3")), ["python3"]);
|
||||
});
|
||||
|
||||
test("win32 prefers python3 when the Microsoft Store build provides it", () => {
|
||||
assert.deepEqual(resolvePythonCommand("win32", probeFor("python3", "python", "py")), ["python3"]);
|
||||
});
|
||||
|
||||
test("win32 falls back to python.exe when python3 is absent (python.org install)", () => {
|
||||
// The exact reported scenario: no python3, but `python` exists.
|
||||
assert.deepEqual(resolvePythonCommand("win32", probeFor("python", "py")), ["python"]);
|
||||
});
|
||||
|
||||
test("win32 falls back to the py launcher with -3 when only py exists", () => {
|
||||
assert.deepEqual(resolvePythonCommand("win32", probeFor("py")), ["py", "-3"]);
|
||||
});
|
||||
|
||||
test("py launcher is probed as `py -3 --version`, not bare `py`", () => {
|
||||
const seen = [];
|
||||
const probe = (cmd, args) => {
|
||||
seen.push([cmd, ...args]);
|
||||
return cmd === "py";
|
||||
};
|
||||
resolvePythonCommand("win32", probe);
|
||||
assert.deepEqual(seen.at(-1), ["py", "-3", "--version"]);
|
||||
});
|
||||
|
||||
test("falls back to the canonical name (loud failure, unchanged) when nothing runs", () => {
|
||||
// No interpreter anywhere — must not throw, and must return python3 so the
|
||||
// eventual spawn fails exactly as it did before this fix, never worse.
|
||||
assert.deepEqual(
|
||||
resolvePythonCommand("win32", () => false),
|
||||
["python3"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolvePythonCommand("linux", () => false),
|
||||
["python3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("pythonInvocation prepends the resolved prefix ahead of caller args", () => {
|
||||
assert.deepEqual(pythonInvocation(["-c", "import x"], ["python"]), {
|
||||
cmd: "python",
|
||||
args: ["-c", "import x"],
|
||||
});
|
||||
// The py launcher's -3 must stay ahead of the caller's own arguments.
|
||||
assert.deepEqual(pythonInvocation(["-c", "import x"], ["py", "-3"]), {
|
||||
cmd: "py",
|
||||
args: ["-3", "-c", "import x"],
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { heygenAuthHeaders, heygenCredential, heygenJSON } from "./heygen.mjs";
|
||||
import { pythonInvocation } from "./python.mjs";
|
||||
|
||||
// ── provider detection ────────────────────────────────────────────────────────
|
||||
export function heygenAvailable() {
|
||||
@@ -25,7 +26,8 @@ export function heygenAvailable() {
|
||||
}
|
||||
export function elevenlabsAvailable() {
|
||||
if (!process.env.ELEVENLABS_API_KEY) return false;
|
||||
const r = spawnSync("python3", ["-c", "import elevenlabs"], {
|
||||
const { cmd, args } = pythonInvocation(["-c", "import elevenlabs"]);
|
||||
const r = spawnSync(cmd, args, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
return r.status === 0;
|
||||
@@ -214,11 +216,14 @@ export async function synthesizeOne({
|
||||
}) {
|
||||
if (provider === "heygen") return synthesizeHeygen({ text, voiceId, lang, speed, wavAbs });
|
||||
if (provider === "elevenlabs") {
|
||||
const r = await spawnP(
|
||||
"python3",
|
||||
["-c", ELEVENLABS_PY, writeTmpText(text), voiceId, wavAbs],
|
||||
{},
|
||||
);
|
||||
const { cmd, args } = pythonInvocation([
|
||||
"-c",
|
||||
ELEVENLABS_PY,
|
||||
writeTmpText(text),
|
||||
voiceId,
|
||||
wavAbs,
|
||||
]);
|
||||
const r = await spawnP(cmd, args, {});
|
||||
return { ok: r.status === 0 && existsSync(wavAbs), words: null };
|
||||
}
|
||||
// kokoro — via the published CLI; --output is relative to the project dir.
|
||||
|
||||
Reference in New Issue
Block a user