feat(cli): reverse start stops a Free account when the server blocks it (#7017)

When /v1/hyperframes/reverse/start answers gate "block" for plan "free", `reverse start`
saves the session, then exits non-zero. The error and its `fix` carry the upgrade link that
credits the purchase to this run: the server's `upgrade_url`, else the plans page with
`feature=reverse&rs=<session>`. Pro, unknown plans, `record`, and every error still go on.
The reverse-any-video skill tells the agent to pass that exact link on and stop.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Wenbo Zhu
2026-11-02 17:27:31 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e3a7cdd2b8
commit 7da583ec69
6 changed files with 59 additions and 10 deletions
+3 -3
View File
@@ -1518,9 +1518,9 @@ run started and saves the session HeyGen returns in the project's
with the render. It then prints the video's [`measure probe`](#measure) JSON,
with a `reverse` field: whether you are signed in, the plan (`free`, `pro`, or
`null` when unknown), the session, and on Free the `upgrade_url` that credits a
purchase to this run. It never stops the run: signed out,
offline, or on a server without the route, it skips the session and still
probes.
purchase to this run. It stops the run only when HeyGen blocks it for a Free
account: it then exits non-zero with that upgrade link. Signed out, offline, or
on a server without the route, it skips the session and still probes.
```bash
npx hyperframes reverse start ref.mp4 --dir my-rebuild
@@ -31,9 +31,13 @@ vi.mock("../measure/probe.js", () => ({
const { default: start } = await import("./start.js");
const SESSION = "rvs_0f8fad5b-d9cb-469f-a165-70867728950e";
const recorded = (plan: "free" | "pro" | "unknown" = "pro", upgradeUrl?: string): ReverseStart => ({
const recorded = (
plan: "free" | "pro" | "unknown" = "pro",
upgradeUrl?: string,
gate: "record" | "block" = "record",
): ReverseStart => ({
recorded: true,
session: { sessionId: SESSION, plan, gate: "record", ...(upgradeUrl ? { upgradeUrl } : {}) },
session: { sessionId: SESSION, plan, gate, ...(upgradeUrl ? { upgradeUrl } : {}) },
});
let root: string;
@@ -169,6 +173,32 @@ describe("reverse start", () => {
expect(() => readFileSync(join(dir, "hyperframes.json"))).toThrow();
});
it("stops a Free account the server blocks, with its upgrade link as the fix, after saving the session", async () => {
const link = "https://hyperframes.studio/pricing?feature=reverse&rs=1";
server.answer = recorded("free", link, "block");
const dir = join(root, "rebuild");
await expect(run("--dir", dir)).rejects.toThrow(CliRuntimeError);
expect(output()).toMatchObject({ ok: false, error: expect.stringContaining(link), fix: link });
expect(config(dir)).toMatchObject({ reverseSessionId: SESSION });
});
it("builds the attributed plans link when a block names none", async () => {
server.answer = recorded("free", undefined, "block");
await expect(run()).rejects.toThrow(CliRuntimeError);
const link = `https://plans.test/?feature=reverse&rs=${SESSION}`;
expect(output()).toMatchObject({ ok: false, error: expect.stringContaining(link), fix: link });
});
it.each<[string, ReverseStart]>([
["a Pro account under block", recorded("pro", undefined, "block")],
["an unknown plan under block", recorded("unknown", undefined, "block")],
["a Free account under record", recorded("free")],
])("goes on for %s", async (_label, answer) => {
server.answer = answer;
await run();
expect(output()).toMatchObject({ ok: true, width: 1080, reverse: { recorded: true } });
});
it("fails like measure probe when the video cannot be probed, before asking the server", async () => {
probe.fail = true;
await expect(run()).rejects.toThrow(CliRuntimeError);
+19 -2
View File
@@ -2,7 +2,13 @@ import { existsSync } from "node:fs";
import { defineCommand } from "../../utils/defineCommand.js";
import type { Example } from "../_examples.js";
import { jsonArg, runMeasure, videoArg } from "../../measure/command.js";
import { reverseUpgradeUrl, startReverseSession, type ReverseStart } from "../../heygen/reverse.js";
import { Fix } from "../../measure/media.js";
import {
reverseUpgradeUrl,
startReverseSession,
type ReverseSession,
type ReverseStart,
} from "../../heygen/reverse.js";
import {
projectConfigPath,
readProjectConfig,
@@ -41,11 +47,20 @@ function summary(started: ReverseStart, savedTo: string | undefined) {
};
}
function refusalWhenServerBlocksFree(session: ReverseSession): Fix | undefined {
if (session.gate !== "block" || session.plan !== "free") return undefined;
const link = reverseUpgradeUrl(session);
return new Fix(
`Reverse is part of HyperFrames Pro. Upgrade here, then run reverse start again: ${link}`,
link,
);
}
export default defineCommand({
meta: {
name: "start",
description:
"Note a reverse run with HeyGen (signed in: saves its session in the project), then print the video's probe; never stops the run",
"Note a reverse run with HeyGen (signed in: saves its session in the project), then print the video's probe; stops only when HeyGen blocks a Free account",
},
args: {
video: videoArg,
@@ -68,6 +83,8 @@ export default defineCommand({
const savedTo = save
? recordProjectReverseSession(projectDir, started.session.sessionId, SKILL)
: undefined;
const refused = started.recorded ? refusalWhenServerBlocksFree(started.session) : undefined;
if (refused) throw refused;
return { reverse: summary(started, savedTo), ...probe };
});
},
+1 -1
View File
@@ -26,7 +26,7 @@
"files": 76
},
"reverse-any-video": {
"hash": "025850a14e3b6370",
"hash": "fe326730ec89a06b",
"files": 5
},
"slideshow": {
+1 -1
View File
@@ -9,7 +9,7 @@ description: Rebuild any video file as an editable HyperFrames project - motion
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. Reverse videos the person has the right to use.
Reverse is a HyperFrames Pro feature: go on only when signed in on a paid plan, not Free. Open with `npx hyperframes reverse start <video> --dir <project>` (a CLI without it: `auth status`, `measure probe`) and `references/start.md`. Resuming (a `REVERSE.md` or half-built project is there) is a start too: open it the same way first. A step an earlier session gave up on (a re-shoot HeyGen refused) gets one more try, then ask the person. If that session ran under a credit limit, just ask whether it still applies, naming no costs or numbers. Rebuild the video as it is; remix it only when the person asks.
Reverse is a HyperFrames Pro feature: go on only on a paid plan, not Free. Open with `npx hyperframes reverse start <video> --dir <project>` (a CLI without it: `auth status`, `measure probe`) and `references/start.md`; if it refuses, pass on its upgrade link and stop. Resuming (a `REVERSE.md` or half-built project) opens the same way. A step an earlier session gave up on (a re-shoot HeyGen refused) gets one more try, then ask the person. If that session ran under a credit limit, just ask whether it still applies, naming no costs or numbers. Rebuild the video as it is; remix it only when the person asks.
## The original decides the frame
+3 -1
View File
@@ -43,7 +43,9 @@ example:
>
> Reply "done" once you're on Pro and I'll start.
Only on a CLI without `reverse start`, give `! npx hyperframes billing subscribe` instead.
When `reverse start` refuses the run (it exits with an error whose `fix` is the upgrade link), give the person that
exact link and stop; once they reply that they are on Pro, run `reverse start` again. Only on a CLI without
`reverse start`, give `! npx hyperframes billing subscribe` instead.
## Signed in on Pro