mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
feat(skills): Reverse is Pro only; the CLI reads the real plan and links /pricing
Per the Oct 29 decisions: a signed-in Free person is told Reverse is a Pro feature and given `billing subscribe`, and nothing starts until they are on Pro. The CLI read the plan from `plan`/`tier`, which the subscription route never sends, so a Pro account read as no plan. It now reads `subscription.plan_name` (null on Free, unpaid placeholders not counted), shows Free in auth status, and upgrade and credits links go to hyperframes.studio/pricing and /account/plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4b70b52eaa
commit
a0fa9cd6dd
@@ -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}`,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ export interface Plan {
|
||||
mock?: boolean;
|
||||
}
|
||||
|
||||
const UNPAID = new Set(["incomplete", "incomplete_expired"]);
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
@@ -30,15 +32,23 @@ export async function readPlan(): Promise<Plan | null> {
|
||||
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(
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"files": 76
|
||||
},
|
||||
"reverse-any-video": {
|
||||
"hash": "3ede55ed2d1eaa54",
|
||||
"hash": "515f16c74b1116fc",
|
||||
"files": 4
|
||||
},
|
||||
"slideshow": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user