Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"files": 144
},
"faceless-explainer": {
"hash": "b57c98179677e7a0",
"files": 18
"hash": "3ec4145b533d112c",
"files": 19
},
"figma": {
"hash": "0e6e96f5a76ff824",
Expand Down Expand Up @@ -46,7 +46,7 @@
"files": 10
},
"media-use": {
"hash": "7a51f53cf615fe97",
"hash": "c7262e0f652322fd",
"files": 124
},
"motion-graphics": {
Expand Down
4 changes: 2 additions & 2 deletions skills/faceless-explainer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ Goal: Generate narration, word timings, music, and audio metadata from the appro

Start audio after Step 3 approval. Run it in the background, then continue to Step 4. (Sign-in status was already shown in Step 0; the engine falls back automatically.)

**Choose the narration voice from the user's ask before invoking.** If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice <id>`. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference.
**Choose the narration provider and voice from the user's ask before invoking.** Pass an explicit offline or alternate provider with `--provider kokoro|elevenlabs|heygen` (or `HF_TTS_PROVIDER`); the CLI flag takes precedence. If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice <id>`. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference.

`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice <voice-id> &`
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --provider <provider> --voice <voice-id> &`

The audio script handles narration, word timings, BGM lookup from HeyGen's music library, and timing metadata. BGM mood comes from the storyboard's `music:` field. This uses the HeyGen Audio API for retrieval, not generation, and the same `~/.heygen` credential as TTS. For provider details, read `../media-use/audio/references/tts.md`.

Expand Down
3 changes: 2 additions & 1 deletion skills/faceless-explainer/scripts/audio.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ function runGenerate(argv) {
const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")));
const scriptPath = resolve(flag(argv, "script", join(hyperframesDir, "SCRIPT.md")));
const outPath = resolve(flag(argv, "out", join(hyperframesDir, "audio_meta.json")));
const provider = flag(argv, "provider", process.env.HF_TTS_PROVIDER || "auto");
const userVoice = flag(argv, "voice", null);
const speed = Number(flag(argv, "speed", "1.0")) || 1.0;

Expand All @@ -144,7 +145,7 @@ function runGenerate(argv) {
// strict here (no wait-bgm step downstream).
const query = (g.extra && g.extra.music) || g.message || g.arc || "calm cinematic underscore";
const request = {
provider: "auto",
provider,
speed,
lines,
bgm: { mode: "retrieve", query, blob: g.message || "", arc: g.arc || "" },
Expand Down
50 changes: 50 additions & 0 deletions skills/faceless-explainer/scripts/audio.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";

const script = new URL("./audio.mjs", import.meta.url).pathname;

function runAudio({ args = [], env = {} } = {}) {
const dir = mkdtempSync(join(tmpdir(), "faceless-audio-"));
const engine = join(dir, "engine.mjs");
try {
writeFileSync(join(dir, "STORYBOARD.md"), "---\nmessage: Test\n---\n");
writeFileSync(
engine,
`import { readFileSync, writeFileSync } from "node:fs";
const argv = process.argv.slice(2);
const flag = (name) => argv[argv.indexOf(name) + 1];
const request = JSON.parse(readFileSync(flag("--request"), "utf8"));
writeFileSync(new URL("request.json", import.meta.url), JSON.stringify(request));
writeFileSync(flag("--out"), JSON.stringify({ voices: [], bgm: null, sfx: [] }));
`,
);
const result = spawnSync(
process.execPath,
[script, "--hyperframes", dir, "--storyboard", join(dir, "STORYBOARD.md"), ...args],
{ encoding: "utf8", env: { ...process.env, HF_MEDIA_ENGINE: engine, ...env } },
);
assert.equal(result.status, 0, result.stderr);
return JSON.parse(readFileSync(join(dir, "request.json"), "utf8"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

test("passes --provider to the shared audio engine", () => {
assert.equal(runAudio({ args: ["--provider", "kokoro"] }).provider, "kokoro");
});

test("uses HF_TTS_PROVIDER when --provider is omitted", () => {
assert.equal(runAudio({ env: { HF_TTS_PROVIDER: "elevenlabs" } }).provider, "elevenlabs");
});

test("--provider takes precedence over HF_TTS_PROVIDER", () => {
assert.equal(
runAudio({ args: ["--provider", "kokoro"], env: { HF_TTS_PROVIDER: "elevenlabs" } }).provider,
"kokoro",
);
});
8 changes: 5 additions & 3 deletions skills/media-use/audio/scripts/audio.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { heygenAuthHeaders, heygenCredential, loadEnvFromDir } from "./lib/heygen.mjs";
import { heygenAuthHeadersWithRefresh, heygenCredential, loadEnvFromDir } from "./lib/heygen.mjs";
import {
ffprobeDuration,
pickProvider,
Expand Down Expand Up @@ -111,8 +111,10 @@ const speed = Number(speedOverride ?? request.speed ?? 1.0) || 1.0;

// ── env + HeyGen availability (the single switch) ─────────────────────────────
loadEnvFromDir(hyperframesDir);
const heygenOK = heygenCredential() !== null;
const headers = heygenOK ? heygenAuthHeaders() : null;
const heygenCred = heygenCredential();
const headers =
heygenCred?.headers || heygenCred?.refreshable ? await heygenAuthHeadersWithRefresh() : null;
const heygenOK = headers !== null;

// ── merge base: preserve sections not selected by --only ──────────────────────
const prev = existsSync(outPath) ? JSON.parse(readFileSync(outPath, "utf8")) : {};
Expand Down
48 changes: 47 additions & 1 deletion skills/media-use/audio/scripts/audio.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { strict as assert } from "node:assert";
import { spawnSync } from "node:child_process";
import { test } from "node:test";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
Expand All @@ -13,6 +14,51 @@ import { resolveSfx } from "./lib/sfx.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const sfxLibDir = join(HERE, "..", "assets", "sfx"); // same offset the engine uses

test("explicit offline TTS provider bypasses expired HeyGen OAuth", () => {
const dir = mkdtempSync(join(tmpdir(), "mu-audio-expired-auth-"));
const configDir = join(dir, "config");
const requestPath = join(dir, "audio_request.json");
const outPath = join(dir, "audio_meta.json");
const engine = join(HERE, "audio.mjs");
try {
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, "credentials"),
JSON.stringify({
oauth: { access_token: "expired", expires_at: "2000-01-01T00:00:00Z" },
}),
);
writeFileSync(
requestPath,
JSON.stringify({ provider: "kokoro", lines: [], bgm: { mode: "none" } }),
);
const env = { ...process.env, HEYGEN_CONFIG_DIR: configDir };
delete env.HEYGEN_API_KEY;
delete env.HYPERFRAMES_API_KEY;
const result = spawnSync(
process.execPath,
[
engine,
"--request",
requestPath,
"--hyperframes",
dir,
"--out",
outPath,
"--only",
"tts",
"--provider",
"kokoro",
],
{ encoding: "utf8", env },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(JSON.parse(readFileSync(outPath, "utf8")).tts_provider, null);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("bundled SFX library resolves from the relocated path", async () => {
assert.ok(existsSync(join(sfxLibDir, "manifest.json")), "moved manifest is present");
const dir = mkdtempSync(join(tmpdir(), "mu-audio-"));
Expand Down
10 changes: 6 additions & 4 deletions skills/media-use/audio/scripts/heygen-tts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { heygenAuthHeaders, heygenJSON, loadEnvFromDir } from "./lib/heygen.mjs";
import { heygenAuthHeadersWithRefresh, heygenJSON, loadEnvFromDir } from "./lib/heygen.mjs";
import { ffprobeDuration, resolveVoiceId, synthesizeOne, withWordIds } from "./lib/tts.mjs";

const argv = process.argv.slice(2);
Expand Down Expand Up @@ -66,7 +66,7 @@ const listOnly = flag("list") === true;
loadEnvFromDir(process.cwd());
let authHeaders;
try {
authHeaders = heygenAuthHeaders();
authHeaders = await heygenAuthHeadersWithRefresh();
} catch (e) {
die(e.message);
}
Expand Down Expand Up @@ -94,7 +94,7 @@ const voiceId = await resolveVoiceId({ provider: "heygen", userVoice, lang });
if (!userVoice) console.error(`· using voice ${voiceId}`);

// ---------- synthesize (shared engine code) ----------
const { ok, words } = await synthesizeOne({
const { ok, words, error } = await synthesizeOne({
provider: "heygen",
text,
voiceId,
Expand All @@ -103,7 +103,9 @@ const { ok, words } = await synthesizeOne({
wavAbs: output,
hyperframesDir: process.cwd(),
});
if (!ok) die("synthesis failed (HeyGen request/transcode error)");
if (!ok) {
die(error ? `synthesis failed: ${error}` : "synthesis failed (HeyGen request/transcode error)");
}

let wordCount = 0;
if (wordsPath) {
Expand Down
66 changes: 65 additions & 1 deletion skills/media-use/audio/scripts/lib/heygen.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";

export const HEYGEN_BASE = "https://api.heygen.com/v3";
const HEYGEN_OAUTH_TOKEN_URL =
process.env.HYPERFRAMES_OAUTH_TOKEN_URL || "https://api2.heygen.com/v1/oauth/token";
const HEYGEN_OAUTH_CLIENT_ID =
process.env.HYPERFRAMES_OAUTH_CLIENT_ID || "q2A2QRSke2LrFTPJhoDbHtXh";
export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" };
// Tool-attribution sent on EVERY media-use HeyGen call regardless of auth type, so
// the backend can isolate media-use consumption from other free TTS / avatar video.
Expand Down Expand Up @@ -45,7 +49,7 @@ export function loadEnvFromDir(startDir) {
}
}

// → { headers } | { expired: true } | null. Never throws.
// → { headers } | { expired: true } | { refreshable: true, ... } | null. Never throws.
export function heygenCredential() {
const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
if (envKey) return { headers: { "X-Api-Key": envKey } };
Expand All @@ -68,6 +72,7 @@ export function heygenCredential() {
if (oauth?.access_token) {
const expired = oauth.expires_at && new Date(oauth.expires_at).getTime() - 60_000 < Date.now();
if (!expired) return { headers: { Authorization: `Bearer ${oauth.access_token}` } };
if (oauth.refresh_token) return { expired: true, refreshable: true, file, credentials: cred };
if (!cred.api_key) return { expired: true };
}
if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } };
Expand Down Expand Up @@ -106,6 +111,65 @@ export function heygenAuthHeaders() {
);
}

// Resolve auth for a network request, silently renewing an expired OAuth token
// when the persisted credential includes a refresh token. Access tokens remain
// short-lived; the refresh token provides the no-prompt UX without weakening
// OAuth's revocation and expiry guarantees.
export async function heygenAuthHeadersWithRefresh(fetchImpl = fetch) {
const cred = heygenCredential();
if (!cred?.refreshable) return heygenAuthHeaders();

const oauth = cred.credentials.oauth;
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: oauth.refresh_token,
client_id: HEYGEN_OAUTH_CLIENT_ID,
});
const res = await fetchImpl(HEYGEN_OAUTH_TOKEN_URL, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body: body.toString(),
});
if (!res.ok) {
throw new Error(
`HeyGen OAuth refresh failed (HTTP ${res.status}) — run \`npx hyperframes auth login\``,
);
}
const payload = await res.json().catch(() => null);
const accessToken = payload?.access_token;
if (typeof accessToken !== "string" || !accessToken || /[\r\n\0]/.test(accessToken)) {
throw new Error(
"HeyGen OAuth refresh returned an invalid access token — run `npx hyperframes auth login`",
);
}

const expiresIn = Number(payload.expires_in);
const renewed = {
...oauth,
access_token: accessToken,
refresh_token: payload.refresh_token || oauth.refresh_token,
};
if (typeof payload.token_type === "string" && payload.token_type) {
renewed.token_type = payload.token_type;
}
if (typeof payload.scope === "string" && payload.scope) renewed.scope = payload.scope;
if (Number.isFinite(expiresIn)) {
renewed.expires_at = new Date(Date.now() + Math.max(expiresIn, 30) * 1000).toISOString();
} else {
delete renewed.expires_at;
}
const saved = { ...cred.credentials, oauth: renewed };
writeFileSync(cred.file, `${JSON.stringify(saved, null, 2)}\n`, { mode: 0o600 });
return {
Authorization: `Bearer ${accessToken}`,
...HEYGEN_CLI_SOURCE_HEADERS,
...HEYGEN_CLIENT_SOURCE_HEADERS,
};
}

// Authed JSON request against the v3 API; throws on a non-OK status.
export async function heygenJSON(path, { method = "GET", headers = {}, body } = {}) {
const opts = { method, headers: { ...HEYGEN_CLIENT_SOURCE_HEADERS, ...headers } };
Expand Down
Loading
Loading