diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index 50b5b4ba81..8e488dcea7 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -250,7 +250,7 @@ function collectStatusRows(s: VerifiedStatus): [string, string][] { rows.push([ "HyperFrames:", plan.plan === "none" - ? `no plan · Subscribe: ${pageUrl("plans")}${tag}` + ? `Free · Upgrade to Pro: ${pageUrl("plans")}${tag}` : `${plan.plan} plan, ${plan.credits.toLocaleString("en-US")} credits${tag}`, ]); } diff --git a/packages/cli/src/commands/billing.ts b/packages/cli/src/commands/billing.ts index c2425f1b69..16b7176f33 100644 --- a/packages/cli/src/commands/billing.ts +++ b/packages/cli/src/commands/billing.ts @@ -5,9 +5,7 @@ import { failCommand } from "../utils/commandResult.js"; import { defineCommand } from "../utils/defineCommand.js"; const describe = (p: Plan | null) => - p && p.plan !== "none" - ? `${p.plan} plan, ${p.credits.toLocaleString("en-US")} credits` - : "no plan"; + p && p.plan !== "none" ? `${p.plan} plan, ${p.credits.toLocaleString("en-US")} credits` : "Free"; /** Opens the plans or credits page, then waits until the account shows the change, so the run continues on its own. */ export default defineCommand({ diff --git a/packages/cli/src/heygen/plan.test.ts b/packages/cli/src/heygen/plan.test.ts new file mode 100644 index 0000000000..2395b22efa --- /dev/null +++ b/packages/cli/src/heygen/plan.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../cloud/auth.js", () => ({ + resolveCloudAuthHeaders: async () => ({ "x-api-key": "k" }), + resolveCloudBaseUrl: () => "https://api.test", +})); + +const { readPlan } = await import("./plan.js"); + +const answer = (data: unknown) => + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ code: 100, data }))); + +afterEach(() => vi.unstubAllGlobals()); + +describe("readPlan", () => { + it("reads the plan the way the subscription route sends it", async () => { + answer({ + subscription: { plan_name: "pro", status: "active" }, + usage: { remaining_units: 25000 }, + }); + expect(await readPlan()).toEqual({ plan: "pro", credits: 25000 }); + answer({ subscription: null, usage: { remaining_units: 0 } }); + expect(await readPlan()).toEqual({ plan: "none", credits: 0 }); + answer({ + subscription: { plan_name: "pro", status: "incomplete" }, + usage: { remaining_units: 0 }, + }); + expect((await readPlan())?.plan).toBe("none"); + }); +}); diff --git a/packages/cli/src/heygen/plan.ts b/packages/cli/src/heygen/plan.ts index f3a32a6444..bc47375a9a 100644 --- a/packages/cli/src/heygen/plan.ts +++ b/packages/cli/src/heygen/plan.ts @@ -7,6 +7,8 @@ export interface Plan { mock?: boolean; } +const UNPAID = new Set(["incomplete", "incomplete_expired"]); + const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); @@ -30,15 +32,23 @@ export async function readPlan(): Promise { const data = isRecord(body) && isRecord(body.data) ? body.data : body; if (!res.ok || !isRecord(data)) return mockPlan(); const usage = isRecord(data.usage) ? data.usage : {}; - const plan = String(data.plan ?? data.tier ?? "none") || "none"; - return { plan, credits: Number(usage.remaining_units ?? data.credits ?? 0) || 0 }; + // Shaped as studio-contracts' BillingSubscription: `subscription` is null on Free, and an unpaid placeholder + // carries its plan's name before the first invoice funds it. + const sub = isRecord(data.subscription) ? data.subscription : null; + const paid = !!sub && !UNPAID.has(String(sub.status)); + return { + plan: paid ? String(sub.plan_name ?? "paid") : "none", + credits: Number(usage.remaining_units ?? 0) || 0, + }; } catch { return mockPlan(); } } -export const plansUrl = () => process.env["HYPERFRAMES_PLANS_URL"] || "https://hyperframes.studio/"; -export const creditsUrl = () => process.env["HYPERFRAMES_CREDITS_URL"] || plansUrl(); +export const plansUrl = () => + process.env["HYPERFRAMES_PLANS_URL"] || "https://hyperframes.studio/pricing"; +export const creditsUrl = () => + process.env["HYPERFRAMES_CREDITS_URL"] || "https://hyperframes.studio/account/plan"; /** A plans or credits page link carrying the fields the web side reads (source, needed credits, the waiting step). */ export function pageUrl( diff --git a/packages/cli/src/heygen/v3.test.ts b/packages/cli/src/heygen/v3.test.ts index 6517454d83..8adfbbd3e2 100644 --- a/packages/cli/src/heygen/v3.test.ts +++ b/packages/cli/src/heygen/v3.test.ts @@ -47,7 +47,7 @@ describe("runJob", () => { reply(402, { error: { code: "hf_subscription_required", message: "no plan" } }), ); await expect(runJob("/v3/x", {}, "k", (id) => id)).rejects.toMatchObject({ - fix: "Subscribe: https://hyperframes.studio/?source=cli", + fix: "Subscribe: https://hyperframes.studio/pricing?source=cli", }); vi.stubGlobal("fetch", async () => reply(402, { code: "hf_usage_exhausted", url: "https://pay.test/credits" }), diff --git a/packages/cli/src/heygen/v3.ts b/packages/cli/src/heygen/v3.ts index a106394168..23413699a5 100644 --- a/packages/cli/src/heygen/v3.ts +++ b/packages/cli/src/heygen/v3.ts @@ -38,7 +38,7 @@ function refusal(status: number, body: unknown): Fix { // The fix is the one line the agent relays as printed; the problem says how to wait for the person. if (/hf_subscription_required/i.test(code)) return new Fix( - `this needs a HyperFrames plan; nothing was charged. Run \`npx hyperframes billing subscribe\`: it opens the page and waits`, + `this is a HyperFrames Pro feature; nothing was charged. Run \`npx hyperframes billing subscribe\`: it opens the page and waits`, `Subscribe: ${url ?? pageUrl("plans", { needed })}`, ); if (/hf_usage_exhausted/i.test(code)) diff --git a/skills-manifest.json b/skills-manifest.json index 61ccf3816c..e455daea58 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -26,7 +26,7 @@ "files": 76 }, "reverse-any-video": { - "hash": "3ede55ed2d1eaa54", + "hash": "515f16c74b1116fc", "files": 4 }, "slideshow": { diff --git a/skills/reverse-any-video/SKILL.md b/skills/reverse-any-video/SKILL.md index 893f5efb89..3b0c2cc370 100644 --- a/skills/reverse-any-video/SKILL.md +++ b/skills/reverse-any-video/SKILL.md @@ -11,7 +11,7 @@ metadata: The result is a HyperFrames project whose render matches the original frame for frame and stays editable. Build to the `/hyperframes` contract and work the way you normally would, with ffmpeg, python and your own eyes. This skill holds only what earlier rebuilds got wrong or spent hours on. Reverse videos the person has the right to use. -Sign in first; start only when `npx hyperframes auth status` shows a sign-in. Open with `references/start.md`. Rebuild the video as it is; remix it only when the person asks. +Reverse is a HyperFrames Pro feature: start only when `npx hyperframes auth status` shows a sign-in on a paid plan, not Free. Open with `references/start.md`. Rebuild the video as it is; remix it only when the person asks. ## The original decides the frame diff --git a/skills/reverse-any-video/references/start.md b/skills/reverse-any-video/references/start.md index 8a439b776d..0384436577 100644 --- a/skills/reverse-any-video/references/start.md +++ b/skills/reverse-any-video/references/start.md @@ -21,7 +21,20 @@ video, so they move and speak as they do in the original. For example: > Then it takes about 20–50 minutes: I study the video frame by frame, rebuild every layer as a project you can edit, > and check it against the original until it matches. -## Signed in +## Signed in on Free + +Reverse is part of Pro. Say so once, give the upgrade step, and start nothing until they are on Pro. For example: + +> Reverse is part of HyperFrames Pro: it re-shoots the people in your video with HeyGen. Upgrade by typing this in the +> prompt: +> +> ``` +> ! npx hyperframes billing subscribe +> ``` +> +> It opens the plans page and finishes once you're on Pro. Reply "done" and I'll start. + +## Signed in on Pro One line, then start.