Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/cli/src/commands/media-use/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ export default defineCommand({
script: { type: "string", description: "avatar, voice: the words spoken" },
voice: {
type: "string",
description: "avatar, voice: a HeyGen voice id, or a language for its first voice in it",
description:
"avatar, voice: a HeyGen voice id, or a language (English, Chinese, en, zh-CN) for its first voice in it",
},
seconds: {
type: "string",
Expand Down
51 changes: 51 additions & 0 deletions packages/cli/src/commands/media-use/voices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("../../cloud/auth.js", () => ({
resolveCloudAuthHeaders: async () => ({ "x-api-key": "k" }),
refreshIfNeeded: async <T>(credential: T) => credential,
resolveCloudBaseUrl: () => "https://api.test",
}));

const { default: voices } = await import("./voices.js");

beforeEach(() => {
vi.stubEnv("HYPERFRAMES_CONFIG_DIR", mkdtempSync(join(tmpdir(), "hf-voices-test-")));
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

describe("media-use voices", () => {
it("says the list is the workspace's, not the person's, so a teammate's clone is never taken as theirs", async () => {
vi.stubGlobal(
"fetch",
async () =>
new Response(
JSON.stringify({
data: [{ voice_id: "v1", name: "Teammate clone", language: "English" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const out: string[] = [];
vi.spyOn(console, "log").mockImplementation((line: string) => void out.push(line));
await voices.run!({ args: { json: true } } as never);
const printed = JSON.parse(out.join("\n"));
expect(printed.voices).toEqual([
{ voice_id: "v1", name: "Teammate clone", language: "English", gender: undefined },
]);
expect(printed.whose).toMatch(/a teammate's included/);
expect(printed.whose).toMatch(/never pick one by count, language or name/);
expect(printed.whose).toMatch(
/says it is theirs or that its owner agreed, whether they name it or give its id/,
);
expect(printed.whose).toMatch(
/an id missing here may still be a teammate's clone, never proof of a stock voice/,
);
});
});
8 changes: 6 additions & 2 deletions packages/cli/src/commands/media-use/voices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import { jsonArg, num, runMeasure } from "../../measure/command.js";
import { ownVoices } from "../../heygen/v3.js";

export const examples: Example[] = [
["The voice clones the film's own account can speak with", "hyperframes media-use voices"],
["The voice clones the film's workspace can speak with", "hyperframes media-use voices"],
];

const WHOSE =
"Every clone this workspace can use, a teammate's included: HeyGen does not say who made one. Use a clone only when the person says it is theirs or that its owner agreed, whether they name it or give its id; never pick one by count, language or name. This is one page of at most --limit, so an id missing here may still be a teammate's clone, never proof of a stock voice.";

export default defineCommand({
meta: {
name: "voices",
description:
"List the voice clones on the HyperFrames account that makes the film. Never use `heygen voice list` for this: that is a separate sign-in and can be another account",
"List the voice clones the HyperFrames account's workspace can use, a teammate's included. Use one only when the person names it as their own. Never use `heygen voice list` for this: that is a separate sign-in and can be another account",
},
args: {
limit: { type: "string", description: "How many to list (default 20)" },
Expand All @@ -20,6 +23,7 @@ export default defineCommand({
async run({ args }) {
await runMeasure(async () => ({
voices: await ownVoices(num(args.limit, "--limit", 20)),
whose: WHOSE,
}));
},
});
47 changes: 46 additions & 1 deletion packages/cli/src/heygen/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1021,7 +1021,12 @@ describe("foldShort", () => {
});

describe("pickVoice", () => {
const voice = (id: string, name: string) => ({ voice_id: id.repeat(32), name, gender: "female" });
const voice = (id: string, name: string, language?: string) => ({
voice_id: id.repeat(32),
name,
gender: "female",
language,
});
let catalog = [voice("a", "Lin"), voice("b", "Wei")];
const asked: string[] = [];
const posts: { key: string | null; body: Record<string, unknown> }[] = [];
Expand Down Expand Up @@ -1061,6 +1066,46 @@ describe("pickVoice", () => {
expect(asked[0]).toBe("https://api.test/v3/voices?type=public&language=Chinese&limit=1");
});

for (const [spelled, language] of [
["en", "English"],
["en-US", "English"],
["zh-CN", "Chinese"],
["cmn", "Chinese"],
] as const) {
it(`asks for ${language} voices when --voice is the language code ${spelled}, not for multilingual ones`, async () => {
heygen();
catalog = [voice("a", "Lin", language)];
const r = {
type: "voice" as const,
out: join(project, `assets/${spelled}.mp3`),
voice: spelled,
};
expect((await pickVoice(r)).name).toBe("Lin");
expect(new URL(asked[0]!).searchParams.get("language")).toBe(language);
});
}

it("refuses the multilingual voice HeyGen offers for a language it does not list, instead of using it", async () => {
heygen();
catalog = [voice("g", "Gail in car", "Multilingual")];
const r = { type: "voice" as const, out: join(project, "assets/m.mp3"), voice: "Mandarin" };
await expect(pickVoice(r)).rejects.toMatchObject({
message: "HeyGen has no public voice for Mandarin (it offered a Multilingual one)",
fix: expect.stringContaining("English or Chinese"),
});
});

it("takes a multilingual voice when the request names Multilingual", async () => {
heygen();
catalog = [voice("g", "Gail in car", "Multilingual")];
const r = {
type: "voice" as const,
out: join(project, "assets/ml.mp3"),
voice: "Multilingual",
};
expect((await pickVoice(r)).name).toBe("Gail in car");
});

it("says what to do when HeyGen will not list its voices", async () => {
vi.stubGlobal(
"fetch",
Expand Down
19 changes: 17 additions & 2 deletions packages/cli/src/heygen/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,12 +616,27 @@ export async function pickVoice(r: GenerateRequest): Promise<Voice> {
]);
const kept = keptVoice(project, request);
if (kept) return kept;
const [voice] = await publicVoices(r.voice, 1);
if (!voice) throw new Fix(`HeyGen has no public voice for ${r.voice}`, "--voice <voice id>");
const language = languageName(r.voice);
const [voice] = await publicVoices(language, 1);
const named = voice?.language?.toLowerCase() === language.toLowerCase();
if (!voice || (!named && voice.language?.toLowerCase() === "multilingual"))
throw new Fix(
`HeyGen has no public voice for ${r.voice}${voice ? ` (it offered a ${voice.language} one)` : ""}`,
"--voice <the language's English name, e.g. English or Chinese>, or --voice <voice id>",
);
keepVoice(project, request, voice);
return voice;
}

const LANGUAGE_TAG = /^([a-z]{2,3})(?:[-_][a-z0-9]{2,8})*$/i;
const LANGUAGE_NAMES = new Intl.DisplayNames(["en"], { type: "language", fallback: "none" });

function languageName(spelled: string): string {
const tag = LANGUAGE_TAG.exec(spelled.trim());
if (!tag) return spelled;
return LANGUAGE_NAMES.of(tag[1]!.toLowerCase()) ?? spelled;
}

export function checkRequest(r: GenerateRequest): void {
const route = ROUTES[r.type];
if (!route.ext.includes(extname(r.out).toLowerCase()))
Expand Down
2 changes: 1 addition & 1 deletion skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"files": 2
},
"style-sampling": {
"hash": "e54aca29ef95de2e",
"hash": "5d26c6211fdfc062",
"files": 2
}
}
Expand Down
2 changes: 1 addition & 1 deletion skills/style-sampling/references/remix.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ The person's message carries what it carries. Ask once, together, only what is s

- **Never the creator's face, voice, footage, wordmark or show name.** That rule is about the creator: the person's own clone, their own recording or a credited stock voice are all fine.
- **A presenter runs on the plan.** Their own HeyGen avatar if they have one (`npx hyperframes media-use avatars` lists the looks on the HyperFrames account making the film; never `heygen avatar list`, a separate sign-in that can be another account), otherwise Avatar IV from their photo, both through `npx hyperframes media-use generate --type avatar` with the finished voice, so the lips land on it. HeyGen Video 1 is the fallback when neither works.
- **Settle the voice before any research.** A HeyGen voice id the person gave is the voice: use it and ask nothing. Only part of one (an 8-character prefix): find the full id in `npx hyperframes media-use voices` and ask only when no clone or several match. Anything else: run `npx hyperframes media-use voices`, which lists the clones on the HyperFrames account making the film; never `heygen voice list`, a separate sign-in that can be another account. One clone: use it. Several: ask which. None, or not signed in: ask once for their voice id, a clone they make in HeyGen from 30 s to 3 min of their own reading, or their own recorded narration; with none of these, say in one line that you'll use a HeyGen stock voice, name it in the credits as AI-generated, and go on. Nothing is voiced for it before stop 1: at the voice step, `npx hyperframes media-use generate --type voice --voice <the script's language> --script "<first line>" --out .media/pick.mp3 --plan` names HeyGen's first public voice in that language without making anything; set the kit's voice id to its `voice_id` and credit that voice by name. Nothing else starts until this is settled.
- **Settle the voice before any research.** A HeyGen voice id the person gave, or an 8-character prefix of one, is used only when they say it is theirs, that its owner agreed to this film, or that it is a HeyGen stock voice. Having the id is not that, and an id missing from `npx hyperframes media-use voices` proves nothing (it lists one page), so when they have not said, ask it in the one round of questions. A prefix: find the full id in `npx hyperframes media-use voices --limit 100`, and ask when it matches none or several. A clone they name as theirs, or say its owner agreed to: find it there and use it. That list is every clone the HyperFrames account's workspace can use, a teammate's included, and does not say who made one, so never pick from it by count, language or name; never `heygen voice list`, a separate sign-in that can be another account. Nothing named, or not signed in: ask once for their voice id, a clone they make in HeyGen from 30 s to 3 min of their own reading, or their own recorded narration; with none of these, say in one line that you'll use a HeyGen stock voice, name it in the credits as AI-generated, and go on. Nothing is voiced for it before stop 1: at the voice step, `npx hyperframes media-use generate --type voice --voice <the script's language> --script "<first line>" --out .media/pick.mp3 --plan` names HeyGen's first public voice in that language without making anything; set the kit's voice id to its `voice_id` and credit that voice by name. Nothing else starts until this is settled.
- **Then say what's coming** in one short message that is not a question, and go on without waiting: the working title and hook, the length, and roughly how long it takes. No prices.
- **The length is the person's, and it beats the template's runtime.** Offer about 1 minute first: the cold open, the title beat and one chapter, the end card short. 2 to 4 minutes: REMIX.md's short cut. A full episode: the series' own runtime. Their own words ("90 seconds", "5 min"): that length. No length answered and none in the topic: about 1 minute, and offer the longer cut in the reply. Scale everything to it: script words (the template's script stats at that runtime), facts, sections, music cues, review rounds (one for a minute, two for a full episode); never pad or trim to reach the series' runtime instead.
- **Credits name only who the person names.** Roles and names come from the person, never from a voice list, an account or a placeholder; a role nobody named is left out.
Expand Down
Loading