feat(skills): keep a generate call under the person's credit limit

media-use generate prices each call in HyperFrames credits (an estimate at the
backend's 1,000 credits per list dollar) and --max-credits refuses a call that
would go past what is left of the limit, before anything is sent. media-use and
reverse-any-video pass the limit only when the person names one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Miao Yang
2026-10-29 22:44:22 +08:00
co-authored by Claude Opus 5.5
parent a0f6ca7469
commit 4b70b52eaa
7 changed files with 51 additions and 8 deletions
@@ -10,7 +10,7 @@ import {
type GenerateRequest,
type GenerateType,
} from "../../heygen/generate.js";
import { priceOf } from "../../heygen/pricing.js";
import { checkLimit, creditsOf, priceOf } from "../../heygen/pricing.js";
export const examples: Example[] = [
[
@@ -99,6 +99,11 @@ export default defineCommand({
description: "music: allow singing (default instrumental)",
default: false,
},
"max-credits": {
type: "string",
description:
"The person's credit limit, less what earlier calls used: make nothing when this call would cost more",
},
plan: {
type: "boolean",
description: "Print the price and what would be sent; make nothing",
@@ -133,6 +138,7 @@ export default defineCommand({
instrumental: !args.vocals,
};
const price = priceOf(request);
const credits = creditsOf(price);
if (args.plan) {
checkGuide(request);
return {
@@ -140,11 +146,13 @@ export default defineCommand({
type,
out: request.out,
price,
credits,
next: "after the person agrees to this price, run the same command without --plan",
};
}
checkLimit(credits, num(args["max-credits"], "--max-credits"));
const made = await generate(request, (status) => console.error(`generate: ${status}…`));
return { ...made, price };
return { ...made, price, credits };
});
},
});
+10 -1
View File
@@ -11,7 +11,7 @@ vi.mock("../cloud/auth.js", () => ({
}));
const { generate, parseMask, urlOf } = await import("./generate.js");
const { priceOf } = await import("./pricing.js");
const { checkLimit, creditsOf, priceOf } = await import("./pricing.js");
const ffmpeg = findFFmpeg();
const project = mkdtempSync(join(tmpdir(), "hf-generate-test-"));
writeFileSync(join(project, "index.html"), "<html></html>");
@@ -331,4 +331,13 @@ describe("generate", () => {
).toBe(0.07);
expect(priceOf({ type: "voice", out: "a.mp3" }).usd).toBeNull();
});
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(() => checkLimit(null, 30)).toThrow("cannot be priced ahead");
expect(() => checkLimit(null, undefined)).not.toThrow();
});
});
+25 -1
View File
@@ -1,7 +1,7 @@
// Prices before a paid call, from the HyperFrames app's creation pricing (hyperframes-internal
// packages/desktop/shared/creationPricing.mjs, 2026-10-22). HeyGen charges in units of $1/60, rounded up; the label
// rounds up to the cent. Routes HeyGen does not price ahead say so instead of guessing.
import { probeVideo, round } from "../measure/media.js";
import { Fix, probeVideo, round } from "../measure/media.js";
import { GUIDE_MAX_S, type GenerateRequest } from "./generate.js";
export interface Price {
@@ -73,3 +73,27 @@ export function priceOf(r: GenerateRequest): Price {
};
}
}
// 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.
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);
/** 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 {
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";
if (credits === null)
throw new Fix(
`this call cannot be priced ahead, so it cannot be kept under --max-credits ${limit}; nothing was made`,
fix,
);
if (credits > limit)
throw new Fix(
`this call is about ${credits} credits, over --max-credits ${limit}; nothing was made or charged`,
fix,
);
}
+2 -2
View File
@@ -18,7 +18,7 @@
"files": 13
},
"media-use": {
"hash": "1a7d1b228edc28b9",
"hash": "66f323c1f995aa23",
"files": 117
},
"remotion-to-hyperframes": {
@@ -26,7 +26,7 @@
"files": 76
},
"reverse-any-video": {
"hash": "b7a34caab79eadce",
"hash": "3ede55ed2d1eaa54",
"files": 4
},
"slideshow": {
+1 -1
View File
@@ -35,7 +35,7 @@ It prints one line, `resolved <id> → <path> (<type>, <metadata>)`. List reusab
**Generate.** `npx hyperframes media-use generate` makes images, shots, talking photos, voices and music (`references/heygen.md`).
**Credits.** Plan for the best result; don't swap in cheaper or free options, or drop items, to save credits unless the person asks to save cost. Never tell the person prices, estimates or what a run used; money comes up only when a call is refused. A refused call names a `billing` command: tell the person why, ask them to run it the same way, then retry the step. `--provider <name>` pins a generator the person names. `--local-only` skips every network provider.
**Credits.** Plan for the best result; don't swap in cheaper or free options, or drop items, to save credits unless the person asks to save cost. Never tell the person prices, estimates or what a run used; money comes up only when a call is refused. A credit limit the person sets is the one exception: pass what is left of it as `--max-credits` on every `generate`, subtract each call's `credits`, and when a call is refused for the limit, say what does not fit and ask; never go past it. A refused call names a `billing` command: tell the person why, ask them to run it the same way, then retry the step. `--provider <name>` pins a generator the person names. `--local-only` skips every network provider.
## Looks on real footage
+1 -1
View File
@@ -16,7 +16,7 @@ A refusal names its fix: `api_key_required` needs an API key, `402` or `insuffic
## Making media: `media-use generate`
Make it without quoting a price or asking for a budget; `--plan` only prints what a call would cost, for your own planning; never pass it on. Make a piece again only for a fault you can name (a cut inside it, text in it, the wrong framing), once, never to chase a motion or look score; a `content_policy_violation` gets one reworded try.
Make it without quoting a price or asking for a budget; `--plan` only prints what a call would cost (`credits` is an estimate in HyperFrames credits), for your own planning; never pass it on. When the person names a credit limit, keep under it with `--max-credits` (SKILL.md, Credits). Make a piece again only for a fault you can name (a cut inside it, text in it, the wrong framing), once, never to chase a motion or look score; a `content_policy_violation` gets one reworded try.
```bash
# a still, edited from a reference picture: a clean plate with the words removed
@@ -15,6 +15,8 @@ Every person on screen, speaking or not, is re-shot with HeyGen so the original'
`--motion` is the original shot itself, cut at its cut times: HeyGen follows its body and camera motion and takes the face from the plate. It also copies text burned into the guide, so give one `--mask` per drawn box in that shot (the clip's pixels and seconds). Mask the text's own box, never a full-width band: a gesture under a mask is lost. A person who does not speak gets no `--audio`.
A credit limit the person named: price every shot first with `--plan`, keep the people that fit, pass what is left as `--max-credits`, and ask before cutting a person to stay under it.
4. **Check each shot as it arrives:** no cut inside it (generate prints `cuts_inside`, and refuses a guide with a cut), none of the original's text, the face box within 5% of the original's (`npx hyperframes measure face`). Regenerate a failing shot once; then trim it to its good part.
5. **Frame it:** scale and move each shot so its face box sits on the original's at the shot's start and end (`npx hyperframes measure face <shot> --against <video> --from a --to b` prints the `scale`, `x`, `y` to set), tweening between them only where the original's camera moves. The framing jumps at cuts and nowhere else.
6. **Text behind the person:** where the original's text passes behind them, cut them out of the shot (`npx hyperframes remove-background <shot> -o <cutout>.webm`) and stack shot, text, cutout.