mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): credits are estimated on list price, and --plan stops asking for a price agreement
A HyperFrames plan charges 1,000 credits a list dollar with no unit rounding: a $0.067 image cost 67 credits on dev, where the estimate said 90. The --plan hint asked the agent to get the person to agree to a price, which the signed-in flow no longer does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
659c8248ff
commit
8291f6fa2d
@@ -147,7 +147,7 @@ export default defineCommand({
|
||||
out: request.out,
|
||||
price,
|
||||
credits,
|
||||
next: "after the person agrees to this price, run the same command without --plan",
|
||||
next: "run the same command without --plan to make it",
|
||||
};
|
||||
}
|
||||
checkLimit(credits, num(args["max-credits"], "--max-credits"));
|
||||
|
||||
@@ -334,9 +334,10 @@ describe("generate", () => {
|
||||
|
||||
it("keeps a call under the person's credit limit, or refuses it before sending", () => {
|
||||
const shot = creditsOf(priceOf({ type: "video", out: "a.mp4", seconds: 6 }));
|
||||
expect(shot).toBe(120);
|
||||
expect(() => checkLimit(shot, 120)).not.toThrow();
|
||||
expect(() => checkLimit(shot, 30)).toThrow("about 120 credits, over --max-credits 30");
|
||||
expect(shot).toBe(108);
|
||||
expect(creditsOf(priceOf({ type: "image", out: "a.png" }))).toBe(67);
|
||||
expect(() => checkLimit(shot, 108)).not.toThrow();
|
||||
expect(() => checkLimit(shot, 30)).toThrow("about 108 credits, over --max-credits 30");
|
||||
expect(() => checkLimit(null, 30)).toThrow("cannot be priced ahead");
|
||||
expect(() => checkLimit(null, undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -6,12 +6,19 @@ import { GUIDE_MAX_S, type GenerateRequest } from "./generate.js";
|
||||
|
||||
export interface Price {
|
||||
usd: number | null;
|
||||
/** The list price before HeyGen's unit rounding: what a HyperFrames plan charges credits on. */
|
||||
list: number | null;
|
||||
basis: string;
|
||||
}
|
||||
|
||||
const UNIT = 1 / 60;
|
||||
const charged = (usd: number) => Math.ceil(usd / UNIT - 1e-9) * UNIT;
|
||||
const cents = (usd: number) => Math.ceil(charged(usd) * 100 - 1e-9) / 100;
|
||||
const at = (list: number, basis: string): Price => ({
|
||||
usd: cents(list),
|
||||
list,
|
||||
basis,
|
||||
});
|
||||
|
||||
const GPT_IMAGE: Record<string, number> = {
|
||||
"1:1": 0.05268,
|
||||
@@ -35,54 +42,62 @@ export function priceOf(r: GenerateRequest): Price {
|
||||
case "image": {
|
||||
const refs = r.references?.length ?? 0;
|
||||
if ((r.model ?? "nano-banana-2") === "nano-banana-2")
|
||||
return { usd: cents(NANO_IMAGE), basis: "nano-banana-2, $0.067 an image" };
|
||||
return at(NANO_IMAGE, "nano-banana-2, $0.067 an image");
|
||||
const each = GPT_IMAGE[r.aspect ?? "16:9"] ?? GPT_IMAGE["16:9"]!;
|
||||
return {
|
||||
usd: cents(each + Math.max(0, refs - 1) * 0.0082),
|
||||
basis: `gpt-image-2.5 at ${r.aspect ?? "16:9"}, $0.0082 a reference after the first`,
|
||||
};
|
||||
return at(
|
||||
each + Math.max(0, refs - 1) * 0.0082,
|
||||
`gpt-image-2.5 at ${r.aspect ?? "16:9"}, $0.0082 a reference after the first`,
|
||||
);
|
||||
}
|
||||
case "video": {
|
||||
const seconds = Math.min(15, Math.max(5, Math.round(r.seconds ?? 5)));
|
||||
if (r.audio || r.references?.length || r.motion) {
|
||||
const guide = r.motion ? Math.min(GUIDE_MAX_S, probeVideo(r.motion).pictureDuration) : 0;
|
||||
return {
|
||||
usd: cents((seconds + guide) * VIDEO_REF_768P_PER_S),
|
||||
basis: `heygen-video-1 reference shot at 768p, ${seconds} s${guide ? ` and the ${round(guide, 2)} s guide` : ""} at $0.05 a second`,
|
||||
};
|
||||
const guide = r.motion
|
||||
? Math.min(GUIDE_MAX_S, probeVideo(r.motion).pictureDuration)
|
||||
: 0;
|
||||
return at(
|
||||
(seconds + guide) * VIDEO_REF_768P_PER_S,
|
||||
`heygen-video-1 reference shot at 768p, ${seconds} s${guide ? ` and the ${round(guide, 2)} s guide` : ""} at $0.05 a second`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
usd: cents(seconds * VIDEO_768P_PER_S),
|
||||
basis: `heygen-video-1 at 768p, ${seconds} s at $0.018 a second`,
|
||||
};
|
||||
return at(
|
||||
seconds * VIDEO_768P_PER_S,
|
||||
`heygen-video-1 at 768p, ${seconds} s at $0.018 a second`,
|
||||
);
|
||||
}
|
||||
case "avatar": {
|
||||
const words = (r.script ?? "").split(/\s+/).filter(Boolean).length;
|
||||
const seconds = Math.max(1, Math.floor(words / WORDS_PER_S));
|
||||
return {
|
||||
usd: cents(Math.ceil(seconds * AVATAR_IV_PER_MIN) / 60),
|
||||
basis: `avatar video, about ${seconds} s from ${words} words at $4.83 a minute`,
|
||||
};
|
||||
return at(
|
||||
Math.ceil(seconds * AVATAR_IV_PER_MIN) / 60,
|
||||
`avatar video, about ${seconds} s from ${words} words at $4.83 a minute`,
|
||||
);
|
||||
}
|
||||
case "music":
|
||||
return { usd: cents(MUSIC_PER_TRACK), basis: "lyria-3-pro, $0.08 a track" };
|
||||
return at(MUSIC_PER_TRACK, "lyria-3-pro, $0.08 a track");
|
||||
case "voice":
|
||||
return {
|
||||
usd: null,
|
||||
basis: "HeyGen does not price catalog speech ahead; it is charged per character",
|
||||
list: null,
|
||||
basis:
|
||||
"HeyGen does not price catalog speech ahead; it is charged per character",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// HyperFrames price rows are written at 1,000 credits per dollar of list price (experiment-framework
|
||||
// heygen/services/payments/hyperframes/catalog.py, HF_CREDITS_PER_USD), so this is an estimate, not the price book.
|
||||
// HyperFrames price rows are written at 1,000 credits per dollar of list price, with no unit rounding
|
||||
// (experiment-framework heygen/services/payments/hyperframes/catalog.py, HF_CREDITS_PER_USD): a $0.067 image is
|
||||
// charged 67 credits, measured on dev 2026-10-29. Still an estimate, not the price book.
|
||||
const HF_CREDITS_PER_USD = 1000;
|
||||
|
||||
export const creditsOf = (p: Price): number | null =>
|
||||
p.usd === null ? null : Math.ceil(p.usd * HF_CREDITS_PER_USD - 1e-9);
|
||||
p.list === null ? null : Math.ceil(p.list * HF_CREDITS_PER_USD - 1e-9);
|
||||
|
||||
/** Refuses, before anything is sent, a call that would not fit what is left of the person's credit limit. */
|
||||
export function checkLimit(credits: number | null, limit: number | undefined): void {
|
||||
export function checkLimit(
|
||||
credits: number | null,
|
||||
limit: number | undefined,
|
||||
): void {
|
||||
if (limit === undefined) return;
|
||||
const fix =
|
||||
"tell the person what their limit leaves out, then ask whether to raise it or make the video without this piece";
|
||||
|
||||
Reference in New Issue
Block a user