From 7da583ec6922fd8fd06f7b377bdcf740d96891ed Mon Sep 17 00:00:00 2001 From: Wenbo Zhu Date: Mon, 2 Nov 2026 17:27:31 +0000 Subject: [PATCH] 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=`. 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 --- docs/packages/cli.mdx | 6 ++-- .../cli/src/commands/reverse/start.test.ts | 34 +++++++++++++++++-- packages/cli/src/commands/reverse/start.ts | 21 ++++++++++-- skills-manifest.json | 2 +- skills/reverse-any-video/SKILL.md | 2 +- skills/reverse-any-video/references/start.md | 4 ++- 6 files changed, 59 insertions(+), 10 deletions(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 0ef9bbf420..3f37bbd85a 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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 diff --git a/packages/cli/src/commands/reverse/start.test.ts b/packages/cli/src/commands/reverse/start.test.ts index 1e628e93ee..f6219a7397 100644 --- a/packages/cli/src/commands/reverse/start.test.ts +++ b/packages/cli/src/commands/reverse/start.test.ts @@ -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); diff --git a/packages/cli/src/commands/reverse/start.ts b/packages/cli/src/commands/reverse/start.ts index 6bd99edf71..fb507d0d53 100644 --- a/packages/cli/src/commands/reverse/start.ts +++ b/packages/cli/src/commands/reverse/start.ts @@ -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 }; }); }, diff --git a/skills-manifest.json b/skills-manifest.json index e5003033ed..3c7e1f1777 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -26,7 +26,7 @@ "files": 76 }, "reverse-any-video": { - "hash": "025850a14e3b6370", + "hash": "fe326730ec89a06b", "files": 5 }, "slideshow": { diff --git a/skills/reverse-any-video/SKILL.md b/skills/reverse-any-video/SKILL.md index 604b8d80f8..388d640ea8 100644 --- a/skills/reverse-any-video/SKILL.md +++ b/skills/reverse-any-video/SKILL.md @@ -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