85453da49f
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
163 lines
6.3 KiB
JavaScript
163 lines
6.3 KiB
JavaScript
import { track } from "./telemetry.mjs";
|
|
|
|
// v0.3.0 is the first CLI that can use an OAuth session; v0.1.x/0.2.x reject it
|
|
// ("heygen-cli can't use OAuth yet"), and OAuth is what the free-usage path
|
|
// needs — so anything below this can't authenticate for free usage at all.
|
|
export const HEYGEN_MIN_VERSION = "0.3.0";
|
|
// Free-usage path is OAuth (`--oauth` → subscription/free credits); `--api-key`
|
|
// bills API credits, so the onboarding steers to OAuth.
|
|
export const HEYGEN_INSTALL_COMMAND =
|
|
"curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --oauth";
|
|
export const HEYGEN_AUTH_COMMAND = "heygen auth login --oauth";
|
|
export const HEYGEN_UPDATE_COMMAND = "heygen update";
|
|
|
|
export const HEYGEN_NOT_FOUND_MESSAGE = `media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. Install: ${HEYGEN_INSTALL_COMMAND}`;
|
|
export const HEYGEN_NOT_AUTHENTICATED_MESSAGE = `media-use: heygen CLI not authenticated (free usage) — run: ${HEYGEN_AUTH_COMMAND}`;
|
|
export const HEYGEN_OUTDATED_MESSAGE = `media-use: heygen CLI is outdated — run: ${HEYGEN_UPDATE_COMMAND} (need >= v${HEYGEN_MIN_VERSION})`;
|
|
|
|
const ACTIONABLE_MESSAGES = new Set([
|
|
HEYGEN_NOT_FOUND_MESSAGE,
|
|
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
|
|
HEYGEN_OUTDATED_MESSAGE,
|
|
]);
|
|
|
|
export function classifyHeygenError(err) {
|
|
return classifyHeygenErrorResult(err).message;
|
|
}
|
|
|
|
export function classifyHeygenErrorCode(err) {
|
|
return classifyHeygenErrorResult(err).code;
|
|
}
|
|
|
|
function classifyHeygenErrorResult(err) {
|
|
const detail = heygenErrorDetail(err);
|
|
const text = [err?.stderr, err?.stdout, err?.message, detail]
|
|
.map((value) => textOf(value))
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
const lower = text.toLowerCase();
|
|
|
|
// Only ENOENT (spawn of a missing binary) or a shell's "command not found"
|
|
// mean the CLI itself is absent. A bare "not found" would misfire on the CLI's
|
|
// own resource errors (e.g. a stale voiceId → "voice not found"), whose message
|
|
// embeds the `heygen ...` command line — sending users to reinstall a CLI they
|
|
// just ran successfully. Keep this narrow.
|
|
if (err?.code === "ENOENT" || lower.includes("command not found")) {
|
|
return { code: "not_found", message: HEYGEN_NOT_FOUND_MESSAGE };
|
|
}
|
|
|
|
if (
|
|
lower.includes("unauthorized") ||
|
|
lower.includes("unauthenticated") ||
|
|
// \b401\b, not a bare "401" substring — otherwise request IDs (req-401abc),
|
|
// URLs, and retry-after headers would misclassify as an auth failure.
|
|
/\b401\b/.test(lower) ||
|
|
lower.includes("not logged in") ||
|
|
lower.includes("no api key") ||
|
|
lower.includes("missing api key") ||
|
|
lower.includes("invalid api key") ||
|
|
lower.includes("login required") ||
|
|
lower.includes("auth required") ||
|
|
lower.includes("authentication required")
|
|
) {
|
|
return { code: "not_authenticated", message: HEYGEN_NOT_AUTHENTICATED_MESSAGE };
|
|
}
|
|
|
|
const version = firstSemver(text);
|
|
if (version && versionLessThan(version, HEYGEN_MIN_VERSION)) {
|
|
return { code: "outdated", message: HEYGEN_OUTDATED_MESSAGE };
|
|
}
|
|
|
|
if (
|
|
lower.includes("rate limit") ||
|
|
lower.includes("quota") ||
|
|
lower.includes("insufficient credit") ||
|
|
lower.includes("too many requests") ||
|
|
lower.includes("throttled") ||
|
|
/\b429\b/.test(lower)
|
|
) {
|
|
return { code: "rate_limited", message: detail };
|
|
}
|
|
|
|
return { code: "other", message: detail };
|
|
}
|
|
|
|
// reportHeygenFailure's callers (voice-provider.mjs, heygen-search.mjs) are
|
|
// synchronous and several layers below the CLI's process.exit() calls, so
|
|
// they can't await this tracking call themselves. Stash each attempt's
|
|
// promise here so a caller closer to exit (resolve.mjs) can join it first —
|
|
// same "awaited so a short-lived run flushes it" discipline telemetry.mjs's
|
|
// track() already documents, just reachable from a sync call site.
|
|
const pendingFailureTracking = new Set();
|
|
// resolve.mjs is a single-shot CLI (one resolve per process), so one shared
|
|
// consume-once slot is sufficient. If resolve becomes an in-process/concurrent
|
|
// API, move this state into a per-resolve context before reusing that path.
|
|
let pendingRemediation = null;
|
|
|
|
export function consumeHeygenRemediation() {
|
|
const remediation = pendingRemediation;
|
|
pendingRemediation = null;
|
|
return remediation;
|
|
}
|
|
|
|
export function reportHeygenFailure(err, context, trackEvent = track) {
|
|
const { code, message } = classifyHeygenErrorResult(err);
|
|
if (code === "not_found" || code === "outdated") {
|
|
pendingRemediation = { code, message };
|
|
}
|
|
if (ACTIONABLE_MESSAGES.has(message)) {
|
|
console.error(message);
|
|
} else {
|
|
console.error(`media-use: \`${context}\` failed: ${message}`);
|
|
}
|
|
try {
|
|
const tracked = Promise.resolve(
|
|
trackEvent("media_use_provider_error", { provider: "heygen", reason: code }),
|
|
).catch(() => {});
|
|
pendingFailureTracking.add(tracked);
|
|
void tracked.finally(() => pendingFailureTracking.delete(tracked));
|
|
return tracked;
|
|
} catch {
|
|
// Telemetry must never affect the provider failure path.
|
|
return Promise.resolve();
|
|
}
|
|
}
|
|
|
|
// Awaits every provider-error track fired since the last flush, so a caller
|
|
// about to process.exit() doesn't orphan one mid-request (both are separate,
|
|
// non-keepalive HTTP connections with no ordering guarantee otherwise).
|
|
// Never rejects: each tracked promise already swallows its own failure.
|
|
export async function flushHeygenFailureTracking() {
|
|
if (pendingFailureTracking.size === 0) return;
|
|
await Promise.all(pendingFailureTracking);
|
|
}
|
|
|
|
export function firstSemver(text) {
|
|
const match = String(text || "").match(/\bv?(\d+)\.(\d+)\.(\d+)\b/);
|
|
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
|
|
}
|
|
|
|
export function versionLessThan(version, minimum) {
|
|
const left = versionParts(version);
|
|
const right = versionParts(minimum);
|
|
if (!left || !right) return false;
|
|
for (let i = 0; i < 3; i++) {
|
|
if (left[i] < right[i]) return true;
|
|
if (left[i] > right[i]) return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function heygenErrorDetail(err) {
|
|
return textOf(err?.stderr) || textOf(err?.stdout) || err?.message || String(err);
|
|
}
|
|
|
|
function textOf(value) {
|
|
return value == null ? "" : String(value).trim();
|
|
}
|
|
|
|
function versionParts(version) {
|
|
const match = String(version || "").match(/^v?(\d+)\.(\d+)\.(\d+)$/);
|
|
return match ? match.slice(1).map((part) => Number.parseInt(part, 10)) : null;
|
|
}
|