mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
feat(cli): shared TTS/BGM auth preflight + caption and skill-workflow fixes (#1697)
* fix: handle caption skin workflow * docs(skills): simplify the finalize step across video workflows - Drop --strict-layout; all skills use plain `hyperframes inspect` - Add the caption text_box_overflow false-positive note to faceless-explainer - On a failed check, the orchestrator makes the cheapest safe edit itself (no worker re-dispatch / Step 3 backtrack language) - Snapshot: glance at the stitched contact-sheet.jpg and move on Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auth): onboarding-first `auth status` + shared TTS/BGM preflight When no HeyGen credential is configured, `hyperframes auth status` now prints registration-first guidance instead of a terse error: - Interactive / agent-driven sessions get sign-in guidance led by `hyperframes auth login` (the OAuth step that also creates an account and is shared with heygen-cli), and never steer users to a per-repo `.env`. CI / non-interactive runs get a terse note. Exit 1 is kept so the "am I logged in?" `$?` contract still holds. - It probes which local engine voice/music will fall back to (Kokoro / MusicGen, mirroring the skill resolution order) and whether their Python deps are installed, with a pip hint when missing. `--json` exposes `recommended_action` + `offline_engines` for skills to branch. - `doctor` gains matching "TTS (Kokoro)" / "BGM (MusicGen)" checks via the same shared probe (findPython/hasPythonModules extracted to tts/python.ts; provider resolution in audio/providers.ts). Every TTS/BGM workflow now relays this at Step 0 (setup) instead of improvising its own "missing key" prompt: pr-to-video, product-launch- video, faceless-explainer, website-to-video, music-to-video. The canonical behavior + key-priority table live once in hyperframes-media. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(pr-to-video): scale recommended video length to PR change size Step 0 led with a fixed ~60-90s length default. Now the recommended length is derived from the PR's diff stat (lines added+deleted, nudged by file count) on a tier scale (trivial ~20-40s → large ~110-180s, hard cap ~3 min), reusing the same PR peek already done to infer the angle. The agent states the basis when proposing it, and a huge PR with one headline change still stays tight. User can always override. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(captions): embed brand fonts whose files use separators brandFontFaces() matched font files by stripping only whitespace, so an underscore/hyphen-named file (TT_Norms_Pro_Bold.woff2) never matched the family key "ttnormspro" — captions shipped with no @font-face, the font_family_without_font_face bug. Now both family and filename normalize away all non-alphanumerics; families match longest-key-first so a parent family can't swallow a more specific one's files (TT Norms Pro vs Mono); each file is claimed once; "demibold" ranks before "bold"; and when nothing matches it warns loudly at build time instead of returning "". Also: parseFonts() falls back to h1/h2/title/hero display roles, and the frame-worker + caption authoring docs spell out that only shipped font files render — no system CJK/Devanagari families on the headless renderer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hyperframes-media): enforce sign-in preflight on standalone BGM/TTS A one-off "generate me a BGM" request went straight to local MusicGen without recommending sign-in: bgm.md/tts.md framed the no-credential path as an automatic fallback, so the generation path bypassed the Preflight stop, and the preflight used a bare `hyperframes auth status` that isn't on PATH in a fresh `npx skills` project. - Preflight now applies to one-off generation as well as workflows, uses `npx hyperframes auth status`, and says: if the CLI can't run, still recommend signing in and STOP — never treat "no credential" as a silent green light for local generation. - bgm.md and tts.md point at the Preflight before generating, reframing local generation as the fallback the user opts into, not a default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(auth): add Authentication & API keys guide Document signing in, the keys each capability (voice, music, capture) uses, their resolution priority, and the fully local fallback. Add the guide to the nav and cross-link it from the cloud deploy note and the CLI env-var reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lint): strip HTML comments in a fixpoint loop (CodeQL) Single-pass <!-- --> removal can re-form a complete comment from adjacent markers (e.g. `<<!-- -->!-- ... -->`), letting a decoy <template> survive and hijack the template-boundary match. Loop to a fixpoint, mirroring the captions.mjs precedent; add a regression test that fails on single-pass (2 root findings) and passes on the loop. Also wrap the build-frame.mjs node:fs imports to satisfy oxfmt — the new copyFileSync import pushed the line past the width limit, which was the sole cause of the Format / Preflight CI failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lint): strip HTML comments with a linear scan (CodeQL ReDoS) The fixpoint loop still ran a /<!--[\s\S]*?-->/ regex per pass, which backtracks O(n^2) on inputs with many unterminated "<!--" — CodeQL js/polynomial-redos (high). Looping the same regex (the prescribed fix) never addressed this; only the regex itself does. Replace it with an indexOf-based linear strip in utils.ts (stripHtmlComments), kept in a fixpoint loop so markers that re-form when a comment is removed are still stripped. 200k unterminated "<!--" now strips in ~3ms instead of quadratic time; behavior is otherwise unchanged — unterminated comments are kept verbatim, as the old regex left them. The re-forming regression test still guards it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): make TTS/BGM sign-in guidance accurate and runnable From team review of the not-signed-in onboarding: - OAuth is a `hyperframes auth login` feature only. The separate `heygen` CLI is API-key-only — `heygen auth login` stores a pasted key, it is not OAuth and does not create an account. Stop presenting the two CLIs as the same OAuth/sign-up step. - Use `npx hyperframes` in every imperative and runtime hint. Bare `hyperframes` is not on PATH on a fresh machine (command not found); only `npx hyperframes` is guaranteed. Also updates the JSON recommended_action. - Drop `heygen auth login` from the terminal/skill onboarding: it needs its own install and there is no `npx heygen`, so it was a command-not-found trap. The shared-credential fact stays in the reference docs. Covers the `auth status` guidance + tests, the Authentication docs, the shared hyperframes-media preflight (SKILL, requirements, tts, error hints), and the `npx hyperframes auth status` preflight in every TTS/BGM workflow (pr-to-video, product-launch-video, faceless-explainer, website-to-video, music-to-video). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c7b9bf3386
commit
54cab331d0
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Onboarding guidance shown by `auth status` when nothing is configured.
|
||||
*
|
||||
* Kept separate from `status.ts` so the wording is pure (it depends only
|
||||
* on colors, not on the credential resolver / API client / system probe)
|
||||
* and can be unit-tested without booting the whole CLI dependency graph.
|
||||
* Environment detection lives in `status.ts`; this module only renders.
|
||||
*/
|
||||
|
||||
import { c } from "../../ui/colors.js";
|
||||
|
||||
export interface UnconfiguredContext {
|
||||
/** A human can act on guidance now — a TTY, or a coding agent driving the CLI. */
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
/** The local engine a workflow will fall back to, and whether it's ready. */
|
||||
export interface OfflineEngineLine {
|
||||
capability: "voice" | "music";
|
||||
/** Engine label, e.g. "Kokoro" / "MusicGen". */
|
||||
label: string;
|
||||
/** Deps installed (local) or key present (cloud) — usable right now. */
|
||||
ready: boolean;
|
||||
/** How to make it ready, shown when `ready` is false. */
|
||||
setupHint?: string;
|
||||
}
|
||||
|
||||
/** The recommended first step; sign-in and sign-up are the same OAuth flow. */
|
||||
const RECOMMENDED_ACTION = "npx hyperframes auth login";
|
||||
|
||||
/**
|
||||
* Render the "what offline will use" block from probed engine readiness.
|
||||
* Falls back to a generic one-liner when readiness wasn't probed (e.g. a
|
||||
* caller that didn't want to spawn Python).
|
||||
*/
|
||||
function offlineEngineLines(engines?: OfflineEngineLine[]): string[] {
|
||||
if (!engines || engines.length === 0) {
|
||||
return [
|
||||
c.dim("Prefer offline? Just continue — local engines (Kokoro · MusicGen) need no account."),
|
||||
];
|
||||
}
|
||||
const lines = ["Prefer offline? Workflows will use these local engines:"];
|
||||
for (const e of engines) {
|
||||
const cap = e.capability.padEnd(5);
|
||||
if (e.ready) {
|
||||
lines.push(` ${cap} → ${e.label} ${c.success("✓ ready")}`);
|
||||
} else {
|
||||
lines.push(` ${cap} → ${e.label} ${c.warn("⚠ deps missing")}`);
|
||||
if (e.setupHint) lines.push(` ${c.dim(e.setupHint)}`);
|
||||
}
|
||||
}
|
||||
if (engines.some((e) => !e.ready)) {
|
||||
lines.push(c.dim(" (or run `hyperframes doctor` to check the local toolchain)"));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human guidance for an unconfigured machine — registration-first.
|
||||
* Both paths use `npx hyperframes` (zero-install via npm): browser OAuth
|
||||
* (sign-in / sign-up) and `--api-key` both write `~/.heygen`. The separate
|
||||
* `heygen` CLI shares that file but needs its own install (no `npx heygen`),
|
||||
* so it's left to the docs — not dangled here as a command a fresh machine
|
||||
* can't run. Names the local fallback so "no key" never reads as a failure,
|
||||
* and never steers users toward a per-repo `.env`. Mirrors the
|
||||
* hyperframes-media skill's Preflight section.
|
||||
*/
|
||||
export function buildUnconfiguredLines(
|
||||
ctx: UnconfiguredContext,
|
||||
engines?: OfflineEngineLine[],
|
||||
): string[] {
|
||||
if (!ctx.interactive) {
|
||||
return [
|
||||
c.warn("Not signed in to HeyGen (non-interactive)."),
|
||||
c.dim(
|
||||
"Set HEYGEN_API_KEY to use HeyGen, or workflows fall back to local engines (Kokoro voice · MusicGen music).",
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
c.warn("Not signed in to HeyGen — voice & music will use local engines (free, offline)."),
|
||||
"",
|
||||
"Sign in or sign up (browser OAuth, writes ~/.heygen — no per-repo .env):",
|
||||
` ${c.accent("npx hyperframes auth login")} ${c.dim("# browser sign-in / sign-up")}`,
|
||||
"",
|
||||
"Or paste an existing HeyGen API key (get one at app.heygen.com/settings/api):",
|
||||
` ${c.accent("npx hyperframes auth login --api-key")} ${c.dim("# paste at the prompt")}`,
|
||||
"",
|
||||
...offlineEngineLines(engines),
|
||||
];
|
||||
}
|
||||
|
||||
/** Machine-readable form of the unconfigured guidance for `--json`. */
|
||||
export function buildUnconfiguredJson(
|
||||
ctx: UnconfiguredContext,
|
||||
engines?: OfflineEngineLine[],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
configured: false,
|
||||
interactive: ctx.interactive,
|
||||
recommended_action: RECOMMENDED_ACTION,
|
||||
fallback: "local",
|
||||
...(engines ? { offline_engines: engines } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildUnconfiguredJson,
|
||||
buildUnconfiguredLines,
|
||||
type OfflineEngineLine,
|
||||
type UnconfiguredContext,
|
||||
} from "./status-guidance.js";
|
||||
|
||||
const INTERACTIVE: UnconfiguredContext = { interactive: true };
|
||||
const NON_INTERACTIVE: UnconfiguredContext = { interactive: false };
|
||||
|
||||
function joined(ctx: UnconfiguredContext, engines?: OfflineEngineLine[]): string {
|
||||
return buildUnconfiguredLines(ctx, engines).join("\n");
|
||||
}
|
||||
|
||||
describe("buildUnconfiguredLines — interactive (TTY / agent-driven)", () => {
|
||||
const text = joined(INTERACTIVE);
|
||||
|
||||
it("makes browser OAuth the hyperframes path", () => {
|
||||
expect(text).toContain("hyperframes auth login");
|
||||
expect(text).toMatch(/browser oauth/i);
|
||||
expect(text).toMatch(/sign in or sign up/i);
|
||||
});
|
||||
|
||||
it("never steers users toward a per-repo .env", () => {
|
||||
// The improvised flow recommended writing keys into videos/<project>/.env;
|
||||
// this guidance must actively rule that out, not suggest it.
|
||||
expect(text).toContain("no per-repo .env");
|
||||
expect(text).not.toMatch(/paste keys.*\.env/i);
|
||||
});
|
||||
|
||||
it("names the local fallback so 'no key' never reads as a failure", () => {
|
||||
expect(text).toMatch(/Kokoro/);
|
||||
expect(text).toMatch(/MusicGen/);
|
||||
expect(text).toMatch(/free, offline/i);
|
||||
});
|
||||
|
||||
it("shows only zero-install `npx hyperframes` paths, not the separately-installed heygen CLI", () => {
|
||||
expect(text).not.toMatch(/heygen auth login/);
|
||||
expect(text).toContain("npx hyperframes auth login");
|
||||
expect(text).toContain("npx hyperframes auth login --api-key");
|
||||
});
|
||||
|
||||
it("offers the --api-key path as a secondary option", () => {
|
||||
expect(text).toContain("hyperframes auth login --api-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUnconfiguredLines — non-interactive (CI / piped)", () => {
|
||||
const lines = buildUnconfiguredLines(NON_INTERACTIVE);
|
||||
const text = lines.join("\n");
|
||||
|
||||
it("is terse — two lines, no browser walkthrough", () => {
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(text).not.toMatch(/opens your browser/i);
|
||||
});
|
||||
|
||||
it("points at HEYGEN_API_KEY and the local fallback", () => {
|
||||
expect(text).toContain("HEYGEN_API_KEY");
|
||||
expect(text).toMatch(/local engines/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUnconfiguredLines — offline engine readiness", () => {
|
||||
const ready: OfflineEngineLine[] = [
|
||||
{ capability: "voice", label: "Kokoro", ready: true },
|
||||
{ capability: "music", label: "MusicGen", ready: true },
|
||||
];
|
||||
const missing: OfflineEngineLine[] = [
|
||||
{ capability: "voice", label: "Kokoro", ready: true },
|
||||
{
|
||||
capability: "music",
|
||||
label: "MusicGen",
|
||||
ready: false,
|
||||
setupHint: "pip install transformers torch soundfile numpy",
|
||||
},
|
||||
];
|
||||
|
||||
it("shows the resolved engine per capability when ready", () => {
|
||||
const text = joined(INTERACTIVE, ready);
|
||||
expect(text).toMatch(/voice .*Kokoro/);
|
||||
expect(text).toMatch(/music .*MusicGen/);
|
||||
expect(text).toMatch(/ready/);
|
||||
});
|
||||
|
||||
it("surfaces the pip setup hint and doctor pointer when a dep is missing", () => {
|
||||
const text = joined(INTERACTIVE, missing);
|
||||
expect(text).toContain("pip install transformers torch soundfile numpy");
|
||||
expect(text).toMatch(/deps missing/);
|
||||
expect(text).toContain("hyperframes doctor");
|
||||
});
|
||||
|
||||
it("falls back to a generic line when readiness wasn't probed", () => {
|
||||
const text = joined(INTERACTIVE);
|
||||
expect(text).toMatch(/Kokoro/);
|
||||
expect(text).toMatch(/MusicGen/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUnconfiguredJson", () => {
|
||||
it("recommends auth login and reports the local fallback", () => {
|
||||
for (const ctx of [INTERACTIVE, NON_INTERACTIVE]) {
|
||||
const payload = buildUnconfiguredJson(ctx);
|
||||
expect(payload).toMatchObject({
|
||||
configured: false,
|
||||
interactive: ctx.interactive,
|
||||
recommended_action: "npx hyperframes auth login",
|
||||
fallback: "local",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("includes probed engines when provided", () => {
|
||||
const engines: OfflineEngineLine[] = [
|
||||
{ capability: "voice", label: "Kokoro", ready: true },
|
||||
{ capability: "music", label: "MusicGen", ready: false, setupHint: "pip install ..." },
|
||||
];
|
||||
expect(buildUnconfiguredJson(INTERACTIVE, engines)).toMatchObject({ offline_engines: engines });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,14 @@
|
||||
*
|
||||
* Exits non-zero when nothing is configured or the API rejects the
|
||||
* credential, so scripts can check "am I logged in?" with `$?`.
|
||||
*
|
||||
* When nothing is configured the output is onboarding-first: an
|
||||
* interactive session (a TTY, or a coding agent driving the CLI) gets
|
||||
* registration guidance led by `hyperframes auth login` — sign-in and
|
||||
* sign-up are the same OAuth step — while CI / non-interactive runs get
|
||||
* a terse note and continue on local fallbacks. This is the shared
|
||||
* preflight every TTS/BGM workflow relays, so the wording lives in one
|
||||
* place instead of each workflow improvising its own.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
@@ -15,7 +23,15 @@ import {
|
||||
type ResolvedCredential,
|
||||
type UserInfo,
|
||||
} from "../../auth/index.js";
|
||||
import { getSystemMeta } from "../../telemetry/system.js";
|
||||
import { c } from "../../ui/colors.js";
|
||||
import { resolveMusic, resolveVoice } from "../../audio/providers.js";
|
||||
import {
|
||||
buildUnconfiguredJson,
|
||||
buildUnconfiguredLines,
|
||||
type OfflineEngineLine,
|
||||
type UnconfiguredContext,
|
||||
} from "./status-guidance.js";
|
||||
|
||||
interface VerifiedStatus {
|
||||
credential: ResolvedCredential;
|
||||
@@ -54,13 +70,44 @@ export default defineCommand({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Decide whether to show full onboarding guidance or a terse note.
|
||||
* CI is never "interactive" even on a TTY; an agent runtime counts as
|
||||
* interactive because a human is watching its relayed output.
|
||||
*/
|
||||
function detectUnconfiguredContext(): UnconfiguredContext {
|
||||
const sys = getSystemMeta();
|
||||
return { interactive: !sys.is_ci && (sys.is_tty || sys.agent_runtime !== null) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the local voice/music engines a workflow would fall back to.
|
||||
* `hasHeygen` is false here by construction — we only reach this when no
|
||||
* credential resolved — so this reports the offline engines and whether
|
||||
* their Python deps are installed.
|
||||
*/
|
||||
function collectOfflineEngines(): OfflineEngineLine[] {
|
||||
const voice = resolveVoice(false);
|
||||
const music = resolveMusic(false);
|
||||
return [
|
||||
{ capability: "voice", label: voice.label, ready: voice.ready, ...hint(voice.setupHint) },
|
||||
{ capability: "music", label: music.label, ready: music.ready, ...hint(music.setupHint) },
|
||||
];
|
||||
}
|
||||
|
||||
function hint(setupHint: string | undefined): { setupHint?: string } {
|
||||
return setupHint ? { setupHint } : {};
|
||||
}
|
||||
|
||||
function handleUnconfigured(asJson: boolean): never {
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ configured: false }));
|
||||
} else {
|
||||
console.log(c.warn("Not signed in to HeyGen."));
|
||||
console.log(`Run ${c.accent("hyperframes auth login --api-key")} to sign in.`);
|
||||
}
|
||||
const ctx = detectUnconfiguredContext();
|
||||
// Probe engines for JSON (skills parse it) and interactive guidance; skip
|
||||
// the Python probes for terse non-interactive/CI output to stay fast.
|
||||
const engines = asJson || ctx.interactive ? collectOfflineEngines() : undefined;
|
||||
const output = asJson
|
||||
? JSON.stringify(buildUnconfiguredJson(ctx, engines))
|
||||
: buildUnconfiguredLines(ctx, engines).join("\n");
|
||||
console.log(output);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user