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:
WaterrrForever
2026-06-24 23:20:39 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent c7b9bf3386
commit 54cab331d0
41 changed files with 1022 additions and 130 deletions
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { decideMusic, decideVoice, KOKORO_PIP, MUSICGEN_PIP } from "./providers.js";
describe("decideVoice — mirrors the skill's heygen → elevenlabs → kokoro order", () => {
it("prefers HeyGen when configured", () => {
const r = decideVoice({ hasHeygen: true, elevenlabs: true, kokoro: true });
expect(r.engine).toBe("heygen");
expect(r.ready).toBe(true);
});
it("falls to ElevenLabs only when key + module are both present", () => {
expect(decideVoice({ hasHeygen: false, elevenlabs: true, kokoro: true }).engine).toBe(
"elevenlabs",
);
});
it("falls to Kokoro when no cloud provider is usable", () => {
expect(decideVoice({ hasHeygen: false, elevenlabs: false, kokoro: true }).engine).toBe(
"kokoro",
);
});
it("flags Kokoro as not-ready with a pip hint when deps are missing", () => {
const r = decideVoice({ hasHeygen: false, elevenlabs: false, kokoro: false });
expect(r.engine).toBe("kokoro");
expect(r.ready).toBe(false);
expect(r.setupHint).toBe(KOKORO_PIP);
});
it("omits the hint when Kokoro is ready", () => {
expect(
decideVoice({ hasHeygen: false, elevenlabs: false, kokoro: true }).setupHint,
).toBeUndefined();
});
});
describe("decideMusic — mirrors the skill's heygen → lyria → musicgen order", () => {
it("prefers HeyGen, then Lyria, then MusicGen", () => {
expect(decideMusic({ hasHeygen: true, lyria: true, musicgen: true }).engine).toBe("heygen");
expect(decideMusic({ hasHeygen: false, lyria: true, musicgen: true }).engine).toBe("lyria");
expect(decideMusic({ hasHeygen: false, lyria: false, musicgen: true }).engine).toBe("musicgen");
});
it("flags MusicGen as not-ready with a pip hint when deps are missing", () => {
const r = decideMusic({ hasHeygen: false, lyria: false, musicgen: false });
expect(r.engine).toBe("musicgen");
expect(r.ready).toBe(false);
expect(r.setupHint).toBe(MUSICGEN_PIP);
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* Which voice / music engine a workflow will actually use, and whether
* its local dependencies are present. Mirrors the resolution order the
* hyperframes-media skill scripts use, so `auth status` and `doctor`
* report the same engine the render pipeline would pick:
*
* voice: HeyGen Starfish → ElevenLabs (key + `elevenlabs`) → Kokoro (local)
* music: HeyGen library → Lyria (key + `google.genai`) → MusicGen (local)
*
* The decision is split from the probing: `decide*` is pure (unit-tested
* without spawning Python); `gather*` collects the live facts.
*/
import { hasPythonModules } from "../tts/python.js";
/** Python import names probed for each local engine. */
export const KOKORO_MODULES = ["kokoro_onnx", "soundfile"];
export const MUSICGEN_MODULES = ["transformers", "torch", "soundfile", "numpy"];
/** pip one-liners shown when a local engine's deps are missing. */
export const KOKORO_PIP = "pip install kokoro-onnx soundfile";
export const MUSICGEN_PIP = "pip install transformers torch soundfile numpy";
export type VoiceEngine = "heygen" | "elevenlabs" | "kokoro";
export type MusicEngine = "heygen" | "lyria" | "musicgen";
export interface EngineReadiness<E> {
engine: E;
/** Human label, e.g. "Kokoro". */
label: string;
/** A local engine (no account needed) vs a cloud provider keyed by env. */
local: boolean;
/** Usable right now: cloud key present, or local deps installed. */
ready: boolean;
/** Shown when `ready` is false — how to make it ready. */
setupHint?: string;
}
export interface VoiceFacts {
hasHeygen: boolean;
/** ELEVENLABS_API_KEY set AND the `elevenlabs` module importable. */
elevenlabs: boolean;
/** Kokoro's local deps importable. */
kokoro: boolean;
}
export interface MusicFacts {
hasHeygen: boolean;
/** A Gemini/Google key set AND `google.genai` importable. */
lyria: boolean;
/** MusicGen's local deps importable. */
musicgen: boolean;
}
export function decideVoice(f: VoiceFacts): EngineReadiness<VoiceEngine> {
if (f.hasHeygen) return { engine: "heygen", label: "HeyGen Starfish", local: false, ready: true };
if (f.elevenlabs) return { engine: "elevenlabs", label: "ElevenLabs", local: false, ready: true };
return {
engine: "kokoro",
label: "Kokoro",
local: true,
ready: f.kokoro,
...(f.kokoro ? {} : { setupHint: KOKORO_PIP }),
};
}
export function decideMusic(f: MusicFacts): EngineReadiness<MusicEngine> {
if (f.hasHeygen) return { engine: "heygen", label: "HeyGen library", local: false, ready: true };
if (f.lyria) return { engine: "lyria", label: "Lyria (Gemini)", local: false, ready: true };
return {
engine: "musicgen",
label: "MusicGen",
local: true,
ready: f.musicgen,
...(f.musicgen ? {} : { setupHint: MUSICGEN_PIP }),
};
}
/** Collect live voice facts. Skips Python probes when HeyGen is configured. */
function gatherVoiceFacts(hasHeygen: boolean): VoiceFacts {
if (hasHeygen) return { hasHeygen, elevenlabs: false, kokoro: false };
const elevenlabs = Boolean(process.env["ELEVENLABS_API_KEY"]) && hasPythonModules(["elevenlabs"]);
const kokoro = hasPythonModules(KOKORO_MODULES);
return { hasHeygen, elevenlabs, kokoro };
}
/** Collect live music facts. Skips Python probes when HeyGen is configured. */
function gatherMusicFacts(hasHeygen: boolean): MusicFacts {
if (hasHeygen) return { hasHeygen, lyria: false, musicgen: false };
const hasLyriaKey = Boolean(process.env["GEMINI_API_KEY"] || process.env["GOOGLE_API_KEY"]);
const lyria = hasLyriaKey && hasPythonModules(["google.genai"]);
const musicgen = hasPythonModules(MUSICGEN_MODULES);
return { hasHeygen, lyria, musicgen };
}
export function resolveVoice(hasHeygen: boolean): EngineReadiness<VoiceEngine> {
return decideVoice(gatherVoiceFacts(hasHeygen));
}
export function resolveMusic(hasHeygen: boolean): EngineReadiness<MusicEngine> {
return decideMusic(gatherMusicFacts(hasHeygen));
}
@@ -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 });
});
});
+53 -6
View File
@@ -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);
}
+22
View File
@@ -5,6 +5,8 @@ import { platform } from "node:os";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { parseToolVersion, runEnvironmentChecks } from "../browser/preflight.js";
import { KOKORO_MODULES, KOKORO_PIP, MUSICGEN_MODULES, MUSICGEN_PIP } from "../audio/providers.js";
import { hasPythonModules } from "../tts/python.js";
import { VERSION } from "../version.js";
import { getUpdateMeta, withMeta } from "../utils/updateCheck.js";
import {
@@ -158,6 +160,24 @@ async function checkWhisper(): Promise<CheckResult> {
};
}
function checkLocalVoice(): CheckResult {
if (hasPythonModules(KOKORO_MODULES)) return { ok: true, detail: "Kokoro deps installed" };
return {
ok: false,
detail: "Not installed (optional \u2014 local voice fallback)",
hint: KOKORO_PIP,
};
}
function checkLocalMusic(): CheckResult {
if (hasPythonModules(MUSICGEN_MODULES)) return { ok: true, detail: "MusicGen deps installed" };
return {
ok: false,
detail: "Not installed (optional \u2014 local music fallback)",
hint: MUSICGEN_PIP,
};
}
export interface CheckOutcome {
name: string;
ok: boolean;
@@ -227,6 +247,8 @@ export default defineCommand({
checks.push({ name: "Environment", run: checkEnvironment });
checks.push({ name: "whisper-cpp", run: checkWhisper });
checks.push({ name: "TTS (Kokoro)", run: checkLocalVoice });
checks.push({ name: "BGM (MusicGen)", run: checkLocalMusic });
const outcomes: CheckOutcome[] = [];
for (const check of checks) {
+74
View File
@@ -0,0 +1,74 @@
/**
* Shared Python-runtime probes. Used by Kokoro synthesis (which must
* actually `import` a module before using it) and by the `auth status` /
* `doctor` readiness checks (which only need to know whether a module is
* installed, cheaply, without paying the cost of importing heavy packages
* like torch).
*/
import { execFileSync } from "node:child_process";
/** Locate a `python3` (or `python`) on PATH that reports as Python 3. */
export function findPython(): string | undefined {
for (const name of ["python3", "python"]) {
try {
const cmd = process.platform === "win32" ? "where" : "which";
const output = execFileSync(cmd, [name], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
});
const first = output
.split(/\r?\n/)
.map((s) => s.trim())
.find(Boolean);
if (!first) continue;
// Verify it's Python 3
const version = execFileSync(first, ["--version"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
}).trim();
if (version.includes("Python 3")) return first;
} catch {
// not found or not Python 3
}
}
return undefined;
}
/** True if `import <pkg>` succeeds — actually executes the module. */
export function hasPythonPackage(python: string, pkg: string): boolean {
try {
execFileSync(python, ["-c", `import ${pkg}`], {
stdio: ["pipe", "pipe", "pipe"],
timeout: 10_000,
});
return true;
} catch {
return false;
}
}
/**
* True if every module is installed, checked via `importlib.util.find_spec`
* so heavy packages (torch) are never imported — fast enough for a preflight.
* Returns false when no Python 3 is found.
*/
export function hasPythonModules(modules: string[]): boolean {
const python = findPython();
if (!python) return false;
const list = JSON.stringify(modules);
const probe = `import importlib.util,sys; sys.exit(0 if all(importlib.util.find_spec(m) for m in ${list}) else 1)`;
try {
execFileSync(python, ["-c", probe], {
stdio: ["pipe", "pipe", "pipe"],
timeout: 10_000,
});
return true;
} catch {
return false;
}
}
+2 -46
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file complexity
import { execFileSync } from "node:child_process";
import { existsSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
import { join, dirname, basename } from "node:path";
@@ -9,52 +10,7 @@ import {
inferLangFromVoiceId,
type SupportedLang,
} from "./manager.js";
// ---------------------------------------------------------------------------
// Python runtime detection
// ---------------------------------------------------------------------------
function findPython(): string | undefined {
for (const name of ["python3", "python"]) {
try {
const cmd = process.platform === "win32" ? "where" : "which";
const output = execFileSync(cmd, [name], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
});
const first = output
.split(/\r?\n/)
.map((s) => s.trim())
.find(Boolean);
if (!first) continue;
// Verify it's Python 3
const version = execFileSync(first, ["--version"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
}).trim();
if (version.includes("Python 3")) return first;
} catch {
// not found or not Python 3
}
}
return undefined;
}
function hasPythonPackage(python: string, pkg: string): boolean {
try {
execFileSync(python, ["-c", `import ${pkg}`], {
stdio: ["pipe", "pipe", "pipe"],
timeout: 10_000,
});
return true;
} catch {
return false;
}
}
import { findPython, hasPythonPackage } from "./python.js";
// ---------------------------------------------------------------------------
// Inline Python script for Kokoro synthesis
@@ -906,6 +906,19 @@ describe("multiple_root_compositions", () => {
expect(finding).toBeUndefined();
});
it("ignores root-level caption-skin.html source files", async () => {
const project = makeProject(validHtml());
writeFileSync(
join(project.dir, "caption-skin.html"),
'<div data-composition-id="captions" data-width="0" data-height="0"></div>',
);
const { results } = await lintProject(project);
const finding = results[0]?.result.findings.find(
(f) => f.code === "multiple_root_compositions",
);
expect(finding).toBeUndefined();
});
it("ignores HTML files without data-composition-id", async () => {
const project = makeProject(validHtml());
writeFileSync(join(project.dir, "readme.html"), "<html><body>Not a composition</body></html>");
+1
View File
@@ -477,6 +477,7 @@ function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding
const rootHtmlFiles = readdirSync(projectDir).filter((f) => f.endsWith(".html"));
const rootCompositions: string[] = [];
for (const file of rootHtmlFiles) {
if (file === "caption-skin.html") continue;
const content = readFileSync(join(projectDir, file), "utf-8");
if (/data-composition-id/i.test(content)) {
rootCompositions.push(file);
+5 -1
View File
@@ -5,6 +5,7 @@ import {
findRootTag,
collectCompositionIds,
readAttr,
stripHtmlComments,
STYLE_BLOCK_PATTERN,
SCRIPT_BLOCK_PATTERN,
} from "./utils";
@@ -29,7 +30,10 @@ export type { HyperframeLintFinding };
export function buildLintContext(html: string, options: HyperframeLinterOptions = {}): LintContext {
const rawSource = html || "";
let source = rawSource;
// Strip HTML comments before scanning so a commented-out <template> or tag can't
// hijack the boundary match below. Linear + fixpoint (see stripHtmlComments) to
// stay ReDoS-free and catch markers that re-form when a comment is removed.
let source = stripHtmlComments(rawSource);
const templateMatch = source.match(/<template[^>]*>([\s\S]*)<\/template>/i);
if (templateMatch?.[1]) source = templateMatch[1];
@@ -63,4 +63,62 @@ describe("lintHyperframeHtml — orchestrator", () => {
);
expect(missing).toHaveLength(0);
});
it("ignores comments that mention template tags before the real template", async () => {
const html = `<!doctype html>
<html>
<head>
<!-- Authoring note: styles and scripts live inside <template>. -->
</head>
<body>
<template id="my-comp-template">
<style>#root { width: 1920px; height: 1080px; }</style>
<div data-composition-id="my-comp" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["my-comp"] = tl;
</script>
</template>
</body>
</html>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
const rootFindings = result.findings.filter(
(f) => f.code === "root_missing_composition_id" || f.code === "root_missing_dimensions",
);
expect(rootFindings).toHaveLength(0);
});
it("strips comments whose markers re-form after one pass (no decoy template survives)", async () => {
// Adjacent comment markers: removing the inner `<!-- -->` in a single pass
// re-joins `<` + `!-- … -->` into a fresh, complete `<!-- … -->` that a lone
// global replace leaves behind — surfacing a decoy <template> with no
// composition-id. A fixpoint strip removes it; this guards that behavior.
const html = `<!doctype html>
<html>
<body>
<<!-- -->!-- <template id="decoy-template"></template> -->
<template id="my-comp-template">
<style>#root { width: 1920px; height: 1080px; }</style>
<div data-composition-id="my-comp" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["my-comp"] = tl;
</script>
</template>
</body>
</html>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
const rootFindings = result.findings.filter(
(f) => f.code === "root_missing_composition_id" || f.code === "root_missing_dimensions",
);
expect(rootFindings).toHaveLength(0);
});
});
+30
View File
@@ -249,6 +249,36 @@ export function stripJsComments(source: string): string {
return out;
}
// One linear pass that drops every `<!-- … -->` region. Uses indexOf, not a
// `/<!--[\s\S]*?-->/` regex: that pattern backtracks O(n²) on inputs with many
// unterminated "<!--" (CodeQL js/polynomial-redos). An unterminated "<!--" with
// no closing "-->" is kept verbatim, matching the prior regex's no-match behavior.
function stripHtmlCommentsOnce(source: string): string {
let out = "";
let i = 0;
for (;;) {
const start = source.indexOf("<!--", i);
if (start < 0) return out + source.slice(i);
const end = source.indexOf("-->", start + 4);
if (end < 0) return out + source.slice(i);
out += source.slice(i, start);
i = end + 3;
}
}
// Strip HTML comments to a fixpoint. A single pass is not enough: deleting one
// comment can splice adjacent markers into a fresh, complete <!-- … --> (e.g.
// "<<!-- -->!-- … -->" → "<!-- … -->"), which would otherwise survive and let a
// commented-out <template>/tag hijack the linter's tag scan.
export function stripHtmlComments(source: string): string {
let out = source;
for (let prev = ""; prev !== out; ) {
prev = out;
out = stripHtmlCommentsOnce(out);
}
return out;
}
export function extractScriptTextsAndSrcs(scripts: ExtractedBlock[]): {
texts: string[];
srcs: string[];