feat(plugins): ship portable agent bundles and installation guides (#4608)

* feat(plugins): ship portable agent bundles and installation guides

* fix(plugins): align new manifests with current release

* test(plugins): split caption fixture setup and cleanup

* test(plugins): pass render fixture path as an argument
This commit is contained in:
James Russo
2026-09-27 21:58:51 +00:00
committed by GitHub
parent ff6e2103d7
commit 0a5e3c7fc4
35 changed files with 942 additions and 112 deletions
+10 -9
View File
@@ -6,10 +6,17 @@
"url": "https://hyperframes.heygen.com"
},
"plugins": [
{
"name": "hyperframes",
"source": "./",
"description": "Recommended: all published HyperFrames skills in one versioned bundle. Create and render videos from HTML; load workflows on demand without installing newer standalone skills.",
"homepage": "https://hyperframes.heygen.com",
"license": "Apache-2.0"
},
{
"name": "core-skills",
"source": "./",
"description": "The HyperFrames core set \u2014 install this one: the /hyperframes router, the hyperframes-* domain skills, and media-use. The router installs each creation workflow on demand. The full bundle (all skills at once) is `hyperframes`.",
"description": "Legacy core-only installation. For release-managed bundled workflows, install hyperframes@hyperframes and then uninstall core-skills@hyperframes.",
"homepage": "https://hyperframes.heygen.com",
"license": "Apache-2.0",
"skills": [
@@ -24,13 +31,7 @@
"./skills/hyperframes-studio",
"./skills/media-use"
]
},
{
"name": "hyperframes",
"source": "./",
"description": "All HyperFrames skills in one static bundle. Most setups want `core-skills` instead \u2014 the router installs creation workflows on demand. HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"homepage": "https://hyperframes.heygen.com",
"license": "Apache-2.0"
}
]
],
"description": "HyperFrames video creation skills, versioned and updated as one plugin."
}
+16
View File
@@ -134,6 +134,16 @@ jobs:
- "package.json"
- ".github/workflows/ci.yml"
codex_plugin:
- "skills-manifest.json"
- "packages/cli/package.json"
- "plugin.json"
- "gemini-extension.json"
- ".claude-plugin/**"
- ".cursor-plugin/**"
- "scripts/package-agent-plugin*"
- "scripts/agent-plugin-captions.test.mjs"
- "scripts/set-version*"
- "LICENSE"
- ".codex-plugin/**"
- "assets/**"
- "skills/**"
@@ -643,6 +653,12 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Test portable plugin packaging
run: node --test scripts/package-agent-plugin.test.mjs
- name: Build portable agent plugin
run: node scripts/package-agent-plugin.mjs
- name: Exercise captions from the extracted portable bundle
run: node --test scripts/agent-plugin-captions.test.mjs
- name: Build upload-ready Codex plugin
run: node scripts/package-codex-plugin.mjs
+10
View File
@@ -214,3 +214,13 @@ jobs:
fi
gh release create "v${VERSION}" "${FLAGS[@]}"
fi
- name: Build portable agent plugin
run: node scripts/package-agent-plugin.mjs
- name: Attach portable plugin to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
REPO: ${{ github.repository }}
run: gh release upload "v${VERSION}" dist/hyperframes-agent-plugin.zip dist/hyperframes-agent-plugin.sha256 dist/hyperframes-agent-plugin.json --repo "$REPO" --clobber
+20 -3
View File
@@ -35,7 +35,18 @@ HyperFrames is an open-source framework for turning HTML, CSS, media, and seekab
### With an AI coding agent
Install the HyperFrames skills, then describe the video you want:
For Claude Code, install the versioned plugin:
```bash
claude plugin marketplace add heygen-com/hyperframes
claude plugin install hyperframes@hyperframes
```
Enable auto-update for the **hyperframes** marketplace in `/plugin` → **Marketplaces**,
then use `/hyperframes:hyperframes`. See the [plugin guide](docs/guides/plugins.mdx)
for Copilot, VS Code, Cursor, Gemini CLI, updates, and migration.
For standalone skills (including OpenCode), use:
```bash
npx skills add heygen-com/hyperframes
@@ -55,9 +66,15 @@ The skills teach agents the HyperFrames production loop: plan the video, write v
HyperFrames ships 21 skills agents load on demand. Read `/hyperframes` first — it's the router and capability map; it picks a workflow for any "make me a…" request — video, deck, or composition port — and points to the domain skills below.
Default to the **core set** — the router installs each creation workflow on demand. `npx hyperframes skills update` installs exactly that from anywhere; the interactive picker (`npx skills add heygen-com/hyperframes`) lists it as the "Core Skills" group, nothing pre-selected. The picker is interactive-only — a non-interactive or agent run without `--skill` installs all 21. Use `npx skills add heygen-com/hyperframes --all` to install all 20 deliberately (skips the picker), or `npx skills add heygen-com/hyperframes --skill <name>` for just one (bare name, no leading `/`).
For standalone installation, default to the **core set** — the router installs each creation workflow on demand. `npx hyperframes skills update` installs exactly that from anywhere; the interactive picker (`npx skills add heygen-com/hyperframes`) lists it as the "Core Skills" group, nothing pre-selected. The picker is interactive-only — a non-interactive or agent run without `--skill` installs all 21. Use `npx skills add heygen-com/hyperframes --all` to install the complete published set deliberately (skips the picker), or `npx skills add heygen-com/hyperframes --skill <name>` for just one (bare name, no leading `/`).
Installs stay lean after that: `npx hyperframes init` keeps the **core set** fresh (the router, the `hyperframes-*` domain skills, and `media-use` — plus whatever is already installed; `/figma` stays on demand) and never expands a partial install; the creation workflows install **on demand** — the router runs `npx hyperframes skills update <workflow>` before entering one. Nothing re-pulls the full set behind your back.
Standalone installs stay lean after that: `npx hyperframes init` keeps the **core set** fresh (the router, the `hyperframes-*` domain skills, and `media-use` — plus whatever is already installed; `/figma` stays on demand) and never expands a partial install; the creation workflows install **on demand** — the router runs `npx hyperframes skills update <workflow>` before entering one. Nothing re-pulls the full set behind your back.
### Plugin packages
Plugins bundle the full skill catalog and use their agent's update manager.
`bun run package:agent-plugin` builds the committed portable ZIP, source metadata,
and SHA-256 checksum. See the [maintainer checklist](docs/contributing/agent-plugins.mdx).
### Upload to Codex
+71
View File
@@ -0,0 +1,71 @@
---
title: "Release agent plugins"
description: "Build, verify, and publish HyperFrames plugin packages from one committed release."
---
`skills/` is the canonical published skill source. Claude, Agent Plugins 1.0,
Cursor, Codex, and Gemini manifests share the CLI release version. Keep vendor
manifests for existing clients while the portable format is adopted.
## Build a committed release
Use the normal reviewed [release process](/contributing/release-channels).
`set-version` updates all five plugin manifests. Commit the release before
packaging; package builders intentionally exclude uncommitted changes.
```bash
bun run package:agent-plugin
bun run package:codex-plugin
```
The portable builder writes these files under `dist/`:
- `hyperframes-agent-plugin.zip`: a `hyperframes/` root with manifests, skills,
the skill catalog, brand assets, and license.
- `hyperframes-agent-plugin.json`: source commit, version, skill inventory,
archive size, and SHA-256.
- `hyperframes-agent-plugin.sha256`: the archive checksum.
The publish workflow attaches the portable files to the GitHub release after npm
publication. The Codex ZIP remains a separate submission artifact. Building it
does not update the OpenAI directory listing.
## Verify before advertising support
1. Run packaging tests, skill validation, skill manifest checks, and manifest
validation. Check that no contributor-only skills, LFS pointers needed at
runtime, or dependencies on unbundled repository files entered the payload.
2. In isolated profiles, install the full bundle and check its skill inventory.
Exercise both Claude's namespaced router and each client's native discovery.
3. Upgrade from release N to N+1 through the actual distribution source. Confirm
the new version after reload; verify a pinned installation stays on N.
A local-directory reload is not proof of marketplace auto-update.
4. Run a local title-card workflow. Verify `init` does not refresh global skills,
the CLI version matches the plugin, and plugin files remain unchanged. Check
existing project dependencies without rewriting their lockfiles.
5. Test migration from `core-skills` and duplicate standalone skills, then
uninstall. Preserve unrelated skills and user edits.
6. Record tested client versions, operating systems, source commit, and results
in the PR/release notes. Mark unavailable clients as unverified.
The Claude marketplace keeps the old `core-skills` install identity for migration;
new users should install `hyperframes@hyperframes`. Do not remove the old entry
without testing migration on supported Claude versions.
## Distribution status and rollback
Track self-hosted repository installation, release artifact publication, native
client verification, and public marketplace approval separately. Cursor public
updates require review. OpenAI directory submissions follow their own process.
Never describe a successful manifest check as a verified native installation.
For rollback, reinstall the prior tested release tag or archive and disable
updates temporarily. Do not reuse a version number for changed plugin contents.
If a public marketplace has a separate published revision, roll that back through
its own process too.
## Related topics
- [Install agent plugins](/guides/plugins)
- [Release channels](/contributing/release-channels)
- [Test local changes](/contributing/testing-local-changes)
+2
View File
@@ -948,6 +948,7 @@
"group": "Agent setup",
"pages": [
"guides/authentication",
"guides/plugins",
"guides/skills",
"guides/webmcp"
]
@@ -958,6 +959,7 @@
"contributing",
"contributing/catalog",
"contributing/release-channels",
"contributing/agent-plugins",
"contributing/changelog-process",
"contributing/testing-local-changes",
"contributing/canary-rollouts",
+179
View File
@@ -0,0 +1,179 @@
---
title: "Install the HyperFrames plugin"
sidebarTitle: "Agent plugins"
description: "Install video creation skills as one versioned plugin and let your agent manage updates."
---
The HyperFrames plugin gives your coding agent the complete set of video workflows:
product promos, explainers, captions, motion graphics, and more. Skills load when
needed; you do not have to install each workflow separately.
Choose one installation method per agent. Plugins receive updates through the
agent's plugin manager. [Standalone skills](/guides/skills) remain available for
OpenCode and other agents, or when you prefer the smaller core-only install.
| Agent | Installation route |
| --- | --- |
| Claude Code | HyperFrames' GitHub marketplace |
| Copilot CLI | HyperFrames' GitHub marketplace |
| VS Code / Copilot | Add the GitHub marketplace in the Extensions view |
| Cursor | Import through a team marketplace, or load a local plugin |
| Gemini CLI | Install the HyperFrames extension from GitHub |
| Codex | Use your existing HyperFrames plugin; directory updates are published separately |
| OpenCode | Use standalone skills |
You need Node.js 22+ and npm for the local CLI, plus FFmpeg for rendering.
Install on each machine you use; a plugin does not synchronize your machines.
## Claude Code
Run in your terminal:
```bash
claude plugin marketplace add heygen-com/hyperframes
claude plugin install hyperframes@hyperframes
claude plugin details hyperframes
```
The inventory should include `hyperframes`, `general-video`, and `media-use`.
Start a new session or run `/reload-plugins`, then try:
```text
Using /hyperframes:hyperframes, make a 5-second title card that says "Hello world".
```
**Enable automatic updates:** run `/plugin`, open **Marketplaces**, select
**hyperframes**, and choose **Enable auto-update**. Third-party marketplaces do
not enable this by default. To update manually:
```bash
claude plugin update hyperframes@hyperframes
```
Reload plugins after updating. To remove the plugin:
```bash
claude plugin uninstall hyperframes@hyperframes
```
See [Claude's plugin guide](https://code.claude.com/docs/en/plugins/install)
for project-wide installation and managed update policies.
## GitHub Copilot CLI
```bash
copilot plugin marketplace add heygen-com/hyperframes
copilot plugin install hyperframes@hyperframes
copilot plugin list
```
Start a fresh session and ask Copilot to use the HyperFrames skill to make the
same title card. Check `/skills list` if the skill is not visible.
```bash
copilot plugin update hyperframes
copilot plugin uninstall hyperframes
```
These are explicit updates; do not assume Copilot CLI shares VS Code's update
schedule. See [Copilot's plugin guide](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/plugins-finding-installing).
## VS Code
Open the Extensions view and search `@agentPlugins`. Add
`heygen-com/hyperframes` as a marketplace using the plugin marketplace command,
then install **hyperframes** and verify its skills in Agent Customizations.
Agent plugins must be enabled in VS Code settings.
VS Code checks for updates every 24 hours when `extensions.autoUpdate` is enabled.
Use **Extensions: Check for Extension Updates** for a manual check. Disable or
uninstall the plugin from the Extensions view. See [VS Code's plugin guide](https://code.visualstudio.com/docs/agent-customization/agent-plugins)
for the current UI and workspace recommendations.
## Cursor
For a team marketplace, import `heygen-com/hyperframes` in **Dashboard → Plugins &
MCPs** and add the HyperFrames plugin. Teammates install it from **Customize**.
GitHub marketplace auto-refresh requires the Cursor GitHub App and the marketplace's
**Enable Auto Refresh** setting.
For a local install, copy an extracted HyperFrames plugin folder to
`~/.cursor/plugins/local/hyperframes`, then reload Cursor and check **Customize**.
The folder must contain `plugin.json` and `skills/`. Local copies require manual
replacement to update; they do not subscribe to GitHub changes. Enterprise policy
may disable local plugin imports. Remove the local folder to uninstall that copy.
This repository's manifests are not proof of a public Cursor marketplace listing.
Public listings and their updates go through Cursor review. See [Cursor's plugin
guide](https://cursor.com/docs/plugins) for marketplace and local-install controls.
## Gemini CLI
```bash
gemini extensions install https://github.com/heygen-com/hyperframes --auto-update
gemini extensions list
```
Restart Gemini CLI and ask it to use the HyperFrames skill for the title card.
To manage the extension explicitly:
```bash
gemini extensions update hyperframes
gemini extensions uninstall hyperframes
```
To stay on a specific release, install with `--ref <release-tag>` instead of
`--auto-update`. Choose a release containing `gemini-extension.json`.
See [Gemini's extension guide](https://geminicli.com/docs/extensions/reference/).
The embedded-captions workflow additionally needs project-local Sharp, Puppeteer
with Chromium, and GSAP. Its skill includes the versioned installation command;
keep those dependencies and their lockfile in the caption project. The plugin
itself remains read-only and does not include browsers or model weights.
## Versions and updates
The plugin bundles workflows from one source snapshot and runs the CLI version
recorded in its manifest. Your agent should use its bundled plugin launcher;
it should not run `npx hyperframes skills update` during a plugin workflow.
Existing project dependencies, downloaded media, and external services have their
own versions and are not frozen by the plugin.
Update behavior depends on the client and source ref. For example,
[Cursor GitHub Auto Refresh](https://cursor.com/docs/plugins#keep-plugins-up-to-date)
follows pushes to the tracked branch, so skill content can change without a new
CLI version. The manifest pins the CLI version, not the installed skill commit.
For a repeatable installation, keep a release tag or an extracted release archive
and disable automatic updates. Install that same release on each machine.
Git-based installs may download the full source repository. Release ZIPs contain
only the plugin payload, but loading an extracted ZIP locally requires manual updates.
Only releases with a `hyperframes-agent-plugin.zip` asset provide that portable ZIP.
## Move from standalone skills or core-skills
Install the full plugin and verify its inventory first. In Claude, an older
`core-skills@hyperframes` install can then be removed:
```bash
claude plugin uninstall core-skills@hyperframes
```
Use your standalone skill manager to remove only the old HyperFrames skills for
that agent. Inspect the locations first: other agents may share a global skills
store, and locally edited skills should be backed up. Do not delete an entire
`.agents` or `.claude` directory. Restart the agent and confirm it selects the
plugin copy rather than a duplicate standalone skill.
## If a workflow is missing or stale
1. Check the agent's installed plugin inventory and version.
2. Update using that agent's plugin manager, then reload or restart it.
3. Check whether an old standalone installation is also being loaded.
4. For a pinned release, verify the workflow exists in that release. Do not mix
skills from a newer release into the plugin folder.
## Related topics
- [Make your first video](/quickstart)
- [Install standalone skills](/guides/skills)
- [Plugin release checklist](/contributing/agent-plugins)
+4
View File
@@ -18,6 +18,10 @@ The terminal output is a real run of the command. Afterwards the router reads a
request and chooses the workflow for it, instead of the agent guessing at the
framework's rules.
Prefer a [versioned plugin](/guides/plugins) when your agent supports one. This
page covers standalone installs, including OpenCode. Plugin users should update
through their agent's plugin manager instead of running the commands below.
## Install for a person
Run the interactive installer:
+7 -3
View File
@@ -29,7 +29,7 @@ preview — you only change the URL.
```text
Set this folder up for HyperFrames and make me a video.
1. Run: npx hyperframes skills update
1. If the HyperFrames plugin is installed, use its bundled skills and launcher. Otherwise run: npx hyperframes skills update
2. Then, using /hyperframes, make a 10-second product intro for https://example.com
3. Open the local preview when it is done and tell me the project folder.
```
@@ -37,9 +37,13 @@ Set this folder up for HyperFrames and make me a video.
Swap `https://example.com` for the page you want the video to show. Everything
below is the same thing done by hand, if you would rather see each step.
## 1. Install the skills
## 1. Install the plugin or skills
Run this in your project folder:
For Claude Code, Copilot, VS Code, Cursor, or Gemini CLI, start with the
[plugin installation guide](/guides/plugins). It includes update settings for
each agent. Claude's plugin router is `/hyperframes:hyperframes`.
For standalone skills, run this in your project folder:
```bash
npx skills add heygen-com/hyperframes
+5
View File
@@ -0,0 +1,5 @@
{
"name": "hyperframes",
"version": "0.8.81",
"description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames."
}
+1
View File
@@ -57,6 +57,7 @@
"test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
"package:agent-plugin": "node scripts/package-agent-plugin.mjs",
"package:codex-plugin": "node scripts/package-codex-plugin.mjs",
"upload:docs-images": "bash scripts/upload-docs-images.sh",
"host:registry-assets": "bun run scripts/host-registry-assets.ts",
+3 -1
View File
@@ -945,7 +945,9 @@ export default defineCommand({
console.log("Get started:");
console.log();
if (skipSkills) {
console.log(` ${c.accent("1.")} Install AI coding skills (one-time):`);
console.log(
` ${c.accent("1.")} Use your HyperFrames plugin, or install standalone skills:`,
);
console.log(` ${c.accent("npx hyperframes skills update")}`);
} else {
console.log(
+5 -1
View File
@@ -24,7 +24,11 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`.
> **Skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> **Using a HyperFrames plugin?** Load skills from that installed bundle and follow
> its `hyperframes/references/plugin-installation.md` execution rules. Update via
> the plugin manager, not the standalone commands below.
>
> **Standalone skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> the specific skill you need (the `/hyperframes` router does this automatically before
> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
> everything already installed — neither pulls the full set. Restart the agent session so
+5 -1
View File
@@ -24,7 +24,11 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`.
> **Skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> **Using a HyperFrames plugin?** Load skills from that installed bundle and follow
> its `hyperframes/references/plugin-installation.md` execution rules. Update via
> the plugin manager, not the standalone commands below.
>
> **Standalone skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> the specific skill you need (the `/hyperframes` router does this automatically before
> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
> everything already installed — neither pulls the full set. Restart the agent session so
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "hyperframes",
"description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.8.81",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com",
"url": "https://hyperframes.heygen.com"
},
"homepage": "https://hyperframes.heygen.com",
"repository": "https://github.com/heygen-com/hyperframes",
"license": "Apache-2.0"
}
+211
View File
@@ -0,0 +1,211 @@
// guards: skills/embedded-captions/**, skills/hyperframes/scripts/plugin-cli.mjs, scripts/package-agent-plugin.mjs
import assert from "node:assert/strict";
import { execFileSync, spawn, spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, delimiter } from "node:path";
import { test } from "node:test";
function assertContributorOverrides(dir, scripts) {
const override = join(dir, "contributor checkout");
const built = join(override, "packages/cli/dist/cli.js");
mkdirSync(join(built, ".."), { recursive: true });
writeFileSync(built, "// built CLI fixture");
for (const explicitArg of [true, false]) {
const found = execFileSync(
process.execPath,
[join(scripts, "hf-cli.cjs"), ...(explicitArg ? [override] : [])],
{ encoding: "utf8", env: { ...process.env, HYPERFRAMES_ROOT: explicitArg ? "" : override } },
);
assert.equal(found.trim(), built);
}
}
test("extracted captions helpers invoke the pinned CLI without a checkout", (t) => {
const dir = mkdtempSync(join(tmpdir(), "hf-caption-zip-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
execFileSync("unzip", ["-q", resolve("dist/hyperframes-agent-plugin.zip"), "-d", dir]);
const root = join(dir, "hyperframes");
const version = JSON.parse(readFileSync(join(root, "plugin.json"))).version;
const scripts = join(root, "skills/embedded-captions/scripts");
// Git-based installs include source directories but no built CLI, unlike ZIPs.
mkdirSync(join(root, "packages/cli"), { recursive: true });
assert.equal(
execFileSync(process.execPath, [join(scripts, "hf-cli.cjs")], { encoding: "utf8" }).trim(),
realpathSync(join(root, "skills/hyperframes/scripts/plugin-cli.mjs")),
);
assertContributorOverrides(dir, scripts);
rmSync(join(root, "packages"), { recursive: true });
const project = join(dir, "project with spaces");
const bin = join(dir, "bin");
mkdirSync(project);
mkdirSync(bin);
mkdirSync(join(dir, "home"));
mkdirSync(join(project, "frames_fg"));
writeFileSync(join(project, "source.mp4"), "fixture");
writeFileSync(join(project, "audio.mp3"), "fixture");
writeFileSync(join(project, "index.html"), "<html>caption fixture</html>");
writeFileSync(join(project, "matte.fps"), "24");
const log = join(dir, "invocation.json");
writeFileSync(
join(bin, "npx"),
`#!${process.execPath}\nrequire('node:fs').writeFileSync(${JSON.stringify(log)}, JSON.stringify({args:process.argv.slice(2), skip:process.env.HYPERFRAMES_SKIP_SKILLS})); process.exit(23);`,
{ mode: 0o755 },
);
writeFileSync(join(bin, "ffmpeg"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
writeFileSync(join(bin, "ffprobe"), "#!/bin/sh\nprintf '24/1\\n24/1\\n'\n", { mode: 0o755 });
const env = {
...process.env,
HOME: join(dir, "home"),
HYPERFRAMES_ROOT: "",
TRANSCRIBE_ENGINE: "whisper",
PATH: `${bin}${delimiter}${process.env.PATH}`,
};
for (const [file, command, cliCommand] of [
["matte.cjs", process.execPath, "remove-background"],
["transcribe.cjs", process.execPath, "transcribe"],
["render-and-composite.sh", "bash", "render"],
]) {
rmSync(log, { force: true });
const result = spawnSync(command, [join(scripts, file), project], {
cwd: dir,
env,
encoding: "utf8",
timeout: 20000,
});
assert.notEqual(result.status, 0, "fake CLI failure must propagate");
assert.ok(existsSync(log), `${file}: ${result.stdout} ${result.stderr}`);
const call = JSON.parse(readFileSync(log, "utf8"));
assert.deepEqual(
call.args.slice(0, 3),
["--yes", `hyperframes@${version}`, cliCommand],
result.stderr,
);
assert.equal(call.skip, "1");
}
});
function cleanupRenderFixture(dir, pidFile) {
if (existsSync(pidFile)) {
const { npm, render } = JSON.parse(readFileSync(pidFile));
for (const pid of [npm, render]) {
try {
process.kill(pid, "SIGKILL");
} catch {}
}
}
rmSync(dir, { recursive: true, force: true });
}
function assertProcessesExited(pidFile) {
const pids = JSON.parse(readFileSync(pidFile));
for (const pid of Object.values(pids)) {
const status = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" });
assert.ok(
status.status === 1 || /^\s*Z/.test(status.stdout),
`PID ${pid} survives: ${status.stdout} ${status.stderr}`,
);
}
}
const cases = [
{ code: 0, expectedStatus: 0 },
{ code: 23, expectedStatus: 1 },
...["INT", "TERM", "HUP"].map((cancel) => ({ cancel, expectedStatus: 0 })),
];
for (const { code, cancel, expectedStatus } of cases) {
test(`ZIP render waits for CLI completion: code=${code}, cancel=${cancel}`, async (t) => {
const dir = mkdtempSync(join(tmpdir(), "hf-caption-render-"));
const pidFile = join(dir, "pids.json");
const unrelated = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], {
stdio: "ignore",
});
t.after(() => unrelated.kill());
t.after(() => cleanupRenderFixture(dir, pidFile));
execFileSync("unzip", ["-q", resolve("dist/hyperframes-agent-plugin.zip"), "-d", dir]);
const script = join(
dir,
"hyperframes/skills/embedded-captions/scripts/render-and-composite.sh",
);
const project = join(dir, "project");
const bin = join(dir, "bin");
mkdirSync(join(project, "frames_fg"), { recursive: true });
mkdirSync(bin);
mkdirSync(join(dir, "home"));
writeFileSync(join(project, "index.html"), "<html>fixture</html>");
writeFileSync(join(project, "matte.fps"), "24");
const output = join(project, "bg_plus_caps.mp4");
const child = join(dir, "child.cjs");
writeFileSync(
child,
"const fs=require('node:fs'); setInterval(()=>fs.appendFileSync(process.argv[2],'x'),50);",
);
writeFileSync(
join(bin, "npx"),
`#!${process.execPath}
const fs=require('node:fs');
const child=require('node:child_process').spawn(process.execPath,[${JSON.stringify(child)},${JSON.stringify(output)}],{stdio:'ignore'});
fs.writeFileSync(${JSON.stringify(output)},Buffer.alloc(1200000));
fs.writeFileSync(${JSON.stringify(pidFile)},JSON.stringify({wrapper:process.ppid,npm:process.pid,render:child.pid}));
${cancel ? "setInterval(()=>{},1000);" : `setTimeout(()=>{child.kill(); child.once('exit',()=>process.exit(${code}));},1200);`}
`,
{ mode: 0o755 },
);
// Only downstream media operations are stubbed; process cleanup and PTY are real.
writeFileSync(join(bin, "ffprobe"), "#!/bin/sh\necho 2\n", { mode: 0o755 });
writeFileSync(join(bin, "ffmpeg"), '#!/bin/bash\ntouch "${@: -1}"\n', { mode: 0o755 });
const terminal = `
import os, pty, select, signal, sys, time
pid, fd = pty.fork()
if pid == 0:
os.execvp("bash", ["bash", sys.argv[1], sys.argv[2]])
deadline = time.monotonic() + 15
sent = False
while time.monotonic() < deadline:
if not sent and os.path.exists(sys.argv[3]):
if sys.argv[4] == "INT": os.write(fd, b"\\x03")
else: os.killpg(pid, getattr(signal, "SIG" + sys.argv[4]))
sent = True
if select.select([fd], [], [], 0.05)[0]:
try: os.read(fd, 65536)
except OSError: pass
ended, status = os.waitpid(pid, os.WNOHANG)
if ended:
time.sleep(0.5)
sys.exit(0 if sent and os.waitstatus_to_exitcode(status) != 0 else 1)
os.kill(pid, signal.SIGKILL)
sys.exit(2)
`;
const result = spawnSync(
cancel ? "python3" : "bash",
cancel ? ["-c", terminal, script, project, pidFile, cancel] : [script, project],
{
encoding: "utf8",
timeout: 20000,
env: {
...process.env,
HOME: join(dir, "home"),
HYPERFRAMES_ROOT: "",
PATH: `${bin}${delimiter}${process.env.PATH}`,
},
},
);
assert.equal(result.error, undefined, result.stderr);
assert.equal(result.status, expectedStatus, result.stdout + result.stderr);
assert.equal(unrelated.exitCode, null);
assert.doesNotThrow(() => process.kill(unrelated.pid, 0));
assert.equal(existsSync(join(project, "final.mp4")), code === 0);
assertProcessesExited(pidFile);
const before = readFileSync(output).length;
await new Promise((resolve) => setTimeout(resolve, 300));
assert.equal(readFileSync(output).length, before, "render still writes output after returning");
});
}
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const MANIFESTS = [
"plugin.json",
"gemini-extension.json",
".claude-plugin/plugin.json",
".cursor-plugin/plugin.json",
".codex-plugin/plugin.json",
];
function validateManifests(read) {
const portable = read("plugin.json");
assert.equal(
portable.$schema,
"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"Unexpected Agent Plugins schema",
);
assert.equal(portable.name, "hyperframes", "Invalid plugin identity");
assert.match(
String(portable.version),
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/,
"Invalid plugin release version",
);
for (const path of MANIFESTS) {
const manifest = read(path);
assert.equal(manifest.name, portable.name, `Plugin identity mismatch: ${path}`);
assert.equal(manifest.version, portable.version, `Plugin version mismatch: ${path}`);
}
assert.equal(
read("packages/cli/package.json").version,
portable.version,
"Plugin and CLI versions must match",
);
return portable;
}
function payloadPaths(read) {
const codex = read(".codex-plugin/plugin.json");
const assets = [
...new Set(
[
codex.interface.logo,
codex.interface.composerIcon,
read(".cursor-plugin/plugin.json").logo,
].map((p) => p.replace(/^\.\//, "")),
),
];
if (assets.some((p) => !/^assets\/[\w.-]+$/.test(p))) throw new Error("Unsafe plugin asset path");
return [...MANIFESTS, ...assets, "LICENSE", "skills", "skills-manifest.json"];
}
// Package committed files only, including metadata. Dirty manifests must not
// describe a different release from the files git archive actually exports.
export function packagePlugin(root = ROOT) {
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" });
const read = (path) => JSON.parse(git("show", `HEAD:${path}`));
const portable = validateManifests(read);
const source = git("rev-parse", "HEAD").trim();
const published = Object.keys(read("skills-manifest.json").skills).sort();
const skills = git("ls-tree", "-r", "--name-only", "HEAD", "skills").trim().split("\n");
const found = skills
.filter((p) => /^skills\/[^/]+\/SKILL.md$/.test(p))
.map((p) => p.split("/")[1])
.sort();
if (JSON.stringify(found) !== JSON.stringify(published))
throw new Error("Plugin skills differ from the published catalog");
const paths = payloadPaths(read);
for (const path of paths) git("cat-file", "-e", `HEAD:${path}`);
const out = join(root, "dist");
mkdirSync(out, { recursive: true });
const archive = join(out, "hyperframes-agent-plugin.zip");
git(
"archive",
"--format=zip",
"--prefix=hyperframes/",
"--output",
archive,
"HEAD",
"--",
...paths,
);
const bytes = readFileSync(archive);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const metadata = {
name: portable.name,
version: portable.version,
source,
sha256,
bytes: bytes.length,
skills: published,
};
writeFileSync(
join(out, "hyperframes-agent-plugin.json"),
JSON.stringify(metadata, null, 2) + "\n",
);
writeFileSync(
join(out, "hyperframes-agent-plugin.sha256"),
`${sha256} hyperframes-agent-plugin.zip\n`,
);
return metadata;
}
if (process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url) {
console.log(JSON.stringify(packagePlugin(), null, 2));
}
+86
View File
@@ -0,0 +1,86 @@
// guards: scripts/package-agent-plugin.mjs, plugin.json, gemini-extension.json, .claude-plugin/**, .codex-plugin/**, .cursor-plugin/**, skills/**, skills-manifest.json, assets/**, LICENSE, packages/cli/package.json
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { packagePlugin } from "./package-agent-plugin.mjs";
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), "hf-package-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const git = (...args) =>
execFileSync("git", ["-c", "core.hooksPath=/dev/null", ...args], {
cwd: root,
encoding: "utf8",
});
const write = (path, value) => {
mkdirSync(join(root, path, ".."), { recursive: true });
writeFileSync(join(root, path), typeof value === "string" ? value : JSON.stringify(value));
};
git("init", "-q");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
const manifest = { name: "hyperframes", version: "1.2.3" };
write("plugin.json", {
...manifest,
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
});
write("gemini-extension.json", manifest);
write(".claude-plugin/plugin.json", manifest);
write(".cursor-plugin/plugin.json", { ...manifest, logo: "assets/logo.png" });
write(".codex-plugin/plugin.json", {
...manifest,
interface: { logo: "./assets/logo.png", composerIcon: "./assets/icon.png" },
});
write("packages/cli/package.json", manifest);
write("skills-manifest.json", { skills: { hyperframes: {} } });
write("skills/hyperframes/SKILL.md", "bundled skill");
write(".claude/skills/internal/SKILL.md", "private contributor workflow");
write("assets/logo.png", "logo");
write("assets/icon.png", "icon");
write("LICENSE", "license");
git("add", ".");
git("commit", "-qm", "fixture");
return { root, git, write };
}
test("archives committed release, excludes internals, and records reproducible provenance", (t) => {
const { root, write } = fixture(t);
write("plugin.json", { version: "dirty" });
write("skills/hyperframes/SKILL.md", "uncommitted change");
const first = packagePlugin(root);
const second = packagePlugin(root);
assert.deepEqual(first, second);
assert.equal(first.version, "1.2.3");
assert.deepEqual(first.skills, ["hyperframes"]);
const zip = join(root, "dist/hyperframes-agent-plugin.zip");
assert.equal(first.sha256, createHash("sha256").update(readFileSync(zip)).digest("hex"));
const files = execFileSync("unzip", ["-Z1", zip], { encoding: "utf8" });
assert.match(files, /hyperframes\/LICENSE/);
assert.doesNotMatch(files, /internal|packages\/|marketplace.json/);
assert.equal(
execFileSync("unzip", ["-p", zip, "hyperframes/skills/hyperframes/SKILL.md"], {
encoding: "utf8",
}),
"bundled skill",
);
});
test("rejects mismatched client versions before creating an artifact", (t) => {
const { root, git, write } = fixture(t);
write("gemini-extension.json", { name: "hyperframes", version: "1.2.4" });
git("add", ".");
git("commit", "-qm", "mismatch");
assert.throws(() => packagePlugin(root), /mismatch: gemini-extension.json/);
});
test("rejects a skill missing from the published catalog", (t) => {
const { root, git, write } = fixture(t);
write("skills/unpublished/SKILL.md", "not published");
git("add", ".");
git("commit", "-qm", "unpublished");
assert.throws(() => packagePlugin(root), /published catalog/);
});
+2
View File
@@ -145,6 +145,8 @@ describe("changed-path guard", () => {
"packages/core/package.json",
"packages/sdk/package.json",
".claude-plugin/plugin.json",
"plugin.json",
"gemini-extension.json",
"releases/v1.2.3.md",
],
allowed,
+9 -3
View File
@@ -36,7 +36,13 @@ const PACKAGES = [
"packages/sdk",
];
const PLUGINS = [".claude-plugin", ".codex-plugin", ".cursor-plugin"];
const PLUGINS = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".cursor-plugin/plugin.json",
"plugin.json",
"gemini-extension.json",
];
const ROOT = join(import.meta.dirname, "..");
export const CHANGELOG_REVIEW_TODO = "<!-- TODO: write a 1-2 sentence release summary here. -->";
@@ -118,7 +124,7 @@ function updatePluginVersions(version: string) {
// short arrays inline, but JSON.stringify expands them, which would fail the
// pre-commit format check on the release commit this script creates.
for (const plugin of PLUGINS) {
const pluginPath = join(ROOT, plugin, "plugin.json");
const pluginPath = join(ROOT, plugin);
const text = readFileSync(pluginPath, "utf-8");
const oldVersion = text.match(/"version"\s*:\s*"([^"]*)"/)?.[1] ?? "unknown";
writeFileSync(pluginPath, text.replace(/("version"\s*:\s*)"[^"]*"/, `$1"${version}"`));
@@ -311,7 +317,7 @@ export function docsChangelogEntryHasGeneratedTodo(content: string, marker: stri
export function releaseAllowedPaths(version: string) {
return [
...PACKAGES.map((pkg) => join(pkg, "package.json")),
...PLUGINS.map((plugin) => join(plugin, "plugin.json")),
...PLUGINS,
"docs/changelog.mdx",
join("releases", `v${version}.md`),
];
+5 -5
View File
@@ -2,8 +2,8 @@
"source": "heygen-com/hyperframes",
"skills": {
"embedded-captions": {
"hash": "c6cac07dbd207deb",
"files": 142
"hash": "6a9850a8d250780e",
"files": 143
},
"faceless-explainer": {
"hash": "7fda7a8bb75acabf",
@@ -18,7 +18,7 @@
"files": 5
},
"hyperframes": {
"hash": "93ee5e4fad423610",
"hash": "8dffb9cd5b333c9c",
"files": 29
},
"hyperframes-animation": {
@@ -54,8 +54,8 @@
"files": 1
},
"media-use": {
"hash": "912204bb8f5fd200",
"files": 100
"hash": "ebef14be0621a731",
"files": 101
},
"motion-graphics": {
"hash": "3d3a35ffbe8b7e3e",
+23 -2
View File
@@ -19,6 +19,27 @@ description: >
---
## Runtime prerequisites
Plugin installs use the bundled, manifest-pinned CLI for matting, transcription,
and rendering; no source checkout is required. The local preview and caption
measurement helpers also need Sharp, Puppeteer (with its Chromium browser), and
GSAP. Install these in the **caption project**, not inside the read-only plugin:
```bash
npm install --prefix <project> --save-dev --save-exact sharp@0.35.3 puppeteer@25.8.0 gsap@3.15.0
```
Keep the project's lockfile. If these dependencies already exist, use its locked
versions instead of overwriting them. Bash and FFmpeg/ffprobe must be on PATH.
Matting and transcription may download their own models on first use.
Rendering waits for the CLI to exit successfully before compositing. The old
`HF_TIMEOUT_S` shell watchdog is no longer used: a large partial file is not proof
that rendering finished. An explicit built-checkout argument or `HYPERFRAMES_ROOT`
selects the contributor CLI instead of the plugin pin. Cancel a stalled render normally through the CLI/terminal;
the caption helper does not force-kill or recover a render from a process snapshot.
## Operational flow (TL;DR)
Routed through `/hyperframes`, the intent layer confirms only the input (which clip) and **announces** the identity pick as a deferred ask — the shortlist needs the probed clip, so it stays at step 1 below; the layer's run-shape questions don't apply (the footage is untouched, there is no storyboard to review). A `BRIEF.md`, when present, carries the confirmed input and any user notes — read it first.
@@ -252,8 +273,8 @@ The full **embed-track** playbook lives in **[references/composition-craft.md](r
## Dependencies
- **hyperframes**, built (`packages/cli/dist/cli.js`). Scripts auto-resolve the checkout: `HYPERFRAMES_ROOT` env → repo root if this skill ships _inside_ hyperframes → `~/Downloads/hyperframes`. Build with `bun install && bun run build`.
- **Node-first; two Python touchpoints via `uvx` (no manual installs):** transcription runs WhisperX through `uvx` (word-level timings; falls back per SKILL §transcription), and Theme's `drawon` setpiece shells `python3 scripts/gen-stroke-path.py` at compile time. Everything else runs on the toolchain hyperframes already ships: matting via the hyperframes CLI's **`remove-background`** (u2net_human_seg; weights auto-download once, ~168 MB, to `~/.cache/hyperframes/`), image/alpha math via **`sharp`**, layout/occlusion/overflow via **`puppeteer`**, plus **`ffmpeg`**. The scripts auto-resolve these from the hyperframes checkout — nothing extra to install.
- **HyperFrames CLI:** plugin installs use the bundled manifest-pinned launcher. Source contributors can use a built checkout (`packages/cli/dist/cli.js`) via `HYPERFRAMES_ROOT`, the skill’s source tree, or `~/Downloads/hyperframes`.
- **Node-first; two Python touchpoints via `uvx` (no manual installs):** transcription runs WhisperX through `uvx` (word-level timings; falls back per SKILL §transcription), and Theme's `drawon` setpiece shells `python3 scripts/gen-stroke-path.py` at compile time. Everything else runs on the toolchain hyperframes already ships: matting via the hyperframes CLI's **`remove-background`** (u2net_human_seg; weights auto-download once, ~168 MB, to `~/.cache/hyperframes/`), image/alpha math via **`sharp`**, layout/occlusion/overflow via **`puppeteer`**, plus **`ffmpeg`**. Install Sharp, Puppeteer, and GSAP in the caption project as described in **Runtime prerequisites** above. The helpers check that project first and retain checkout dependency lookup for source contributors.
- **Transcription = WhisperX via `uvx`** (word-level timings + alignment; no manual install — `transcribe.cjs` drives `uvx whisperx`). Falls back to an existing word-level `transcript.json` if present.
- **Source video** — `matte.cjs` / `transcribe.cjs` auto-resolve `source.mp4` (or glob the clip / read `hyperframes.json`), so `hyperframes init --video X.mp4` needs no manual rename.
- **fps** — `matte.cjs` extracts at the source's native rate and records `matte.fps`; `render-and-composite.sh` uses that so the matte stays frame-aligned.
@@ -11,6 +11,7 @@ const cp = require("child_process");
function hfResolve(pkg) {
const roots = [
process.argv[2] && path.resolve(process.argv[2]), // project-owned helper dependencies
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "..", "..", ".."),
path.join(os.homedir(), "Downloads", "hyperframes"),
@@ -29,7 +30,9 @@ function hfResolve(pkg) {
} catch {}
}
}
console.error(`[v2] cannot find ${pkg} — set HYPERFRAMES_ROOT`);
console.error(
`[v2] cannot find ${pkg} — install project dependencies per embedded-captions SKILL.md Runtime prerequisites`,
);
process.exit(3);
}
const sharp = hfResolve("sharp");
@@ -18,6 +18,7 @@ const fs = require("fs");
const os = require("os");
const HF_ROOTS = [
process.argv[2] && path.resolve(process.argv[2]), // project-owned helper dependencies
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "../../.."),
path.join(os.homedir(), "Downloads", "hyperframes"),
@@ -20,6 +20,7 @@ const fs = require("fs");
const os = require("os");
const HF_ROOTS = [
process.argv[2] && path.resolve(process.argv[2]), // project-owned helper dependencies
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "../../.."),
path.join(os.homedir(), "Downloads", "hyperframes"),
@@ -154,7 +155,10 @@ async function main() {
const railPath = path.join(project, "rail.html");
if (!fs.existsSync(railPath) || !fs.existsSync(indexPath))
ok("[rail-climax] no rail.html+index.html — not Standard, skipping");
if (!puppeteer) ok("[rail-climax] puppeteer unavailable — skipping (set HYPERFRAMES_ROOT)");
if (!puppeteer)
ok(
"[rail-climax] puppeteer unavailable — skipping (install project dependencies per embedded-captions SKILL.md Runtime prerequisites)",
);
const exe =
process.platform === "darwin"
@@ -0,0 +1,33 @@
// Resolve CLI ownership once for matting, transcription, and shell rendering.
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
function hfCli(checkout) {
const root = path.resolve(__dirname, "../../..");
for (const dir of [checkout, process.env.HYPERFRAMES_ROOT].filter(Boolean)) {
const cli = path.join(dir, "packages/cli/dist/cli.js");
if (fs.existsSync(cli)) return cli;
}
const manifests = [
"plugin.json",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".cursor-plugin/plugin.json",
"gemini-extension.json",
];
if (
!fs.existsSync(path.join(root, "packages/cli/dist/cli.js")) &&
manifests.some((p) => fs.existsSync(path.join(root, p)))
) {
return path.join(root, "skills/hyperframes/scripts/plugin-cli.mjs");
}
for (const dir of [root, path.join(os.homedir(), "Downloads/hyperframes")].filter(Boolean)) {
const cli = path.join(dir, "packages/cli/dist/cli.js");
if (fs.existsSync(cli)) return cli;
}
throw new Error(
"HyperFrames CLI unavailable: install the full plugin or set HYPERFRAMES_ROOT to a built checkout.",
);
}
module.exports = { hfCli };
if (require.main === module) console.log(hfCli(process.argv[2]));
+1 -13
View File
@@ -30,19 +30,7 @@ const fs = require("fs");
const os = require("os");
const cp = require("child_process");
function hfCli() {
const roots = [
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "..", "..", ".."), // skills/embedded-captions/scripts → repo root if in-repo
path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
for (const root of roots) {
const cli = path.join(root, "packages", "cli", "dist", "cli.js");
if (fs.existsSync(cli)) return cli;
}
console.error("[matte] cannot find hyperframes cli — set HYPERFRAMES_ROOT to a built checkout");
process.exit(3);
}
const { hfCli } = require("./hf-cli.cjs");
function ensureSource(project) {
const src = path.join(project, "source.mp4");
@@ -22,6 +22,7 @@ const os = require("os");
// HYPERFRAMES_ROOT; standalone we also try the in-repo path + ~/Downloads, and
// accept ANY puppeteer@* the bun store holds (not a pinned version).
const HF_ROOTS = [
process.argv[2] && path.resolve(process.argv[2]), // project-owned helper dependencies
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "../../.."), // skills/embedded-captions/scripts → repo root if in-repo
path.join(os.homedir(), "Downloads", "hyperframes"),
@@ -54,7 +55,7 @@ for (const root of HF_ROOTS) {
}
if (!puppeteer) {
console.error(
"[measure] could not locate puppeteer — set HYPERFRAMES_ROOT to a built hyperframes checkout",
"[measure] could not locate puppeteer — install project dependencies per embedded-captions SKILL.md Runtime prerequisites",
);
process.exit(3);
}
@@ -40,6 +40,7 @@ function withPreviewGsapSri(html, gsapSource) {
}
const HF_ROOTS = [
process.argv[2] && path.resolve(process.argv[2]), // project-owned helper dependencies
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "../../.."),
path.join(os.homedir(), "Downloads", "hyperframes"),
@@ -82,7 +83,9 @@ for (const r of HF_ROOTS) {
}
}
if (require.main === module && (!puppeteer || !sharp)) {
console.error("[preview] need puppeteer+sharp — set HYPERFRAMES_ROOT");
console.error(
"[preview] need puppeteer+sharp — install project dependencies per embedded-captions SKILL.md Runtime prerequisites",
);
process.exit(0);
}
@@ -12,21 +12,10 @@ set -euo pipefail
PROJECT="${1:?usage: render-and-composite.sh <project-dir> [hyperframes-repo]}"
PROJECT="$(cd "$PROJECT" && pwd)"
# Resolve the hyperframes checkout. Candidate order:
# 1. arg 2 2. $HYPERFRAMES_ROOT 3. repo root if this skill ships INSIDE the
# hyperframes repo (skills/embedded-captions/scripts → ../../..) 4. ~/Downloads/hyperframes
SKILL_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HF=""
for cand in "${2:-}" "${HYPERFRAMES_ROOT:-}" "$(cd "$SKILL_SCRIPT_DIR/../../.." 2>/dev/null && pwd)" "$HOME/Downloads/hyperframes"; do
if [[ -n "$cand" && -f "$cand/packages/cli/dist/cli.js" ]]; then HF="$cand"; break; fi
done
if [[ -z "$HF" ]]; then
echo "[render] hyperframes CLI not found. Set HYPERFRAMES_ROOT to your hyperframes" >&2
echo " checkout (needs packages/cli/dist/cli.js — 'bun install && bun run build')." >&2
exit 1
fi
export HYPERFRAMES_ROOT="$HF" # so the occlusion gate's measure-layout.cjs finds puppeteer too
HF_CLI="$HF/packages/cli/dist/cli.js"
HF_CLI="$(node "$SKILL_SCRIPT_DIR/hf-cli.cjs" "${2:-}")"
# Retain the explicit dependency-root override for source-checkout users.
if [[ -n "${2:-}" ]]; then export HYPERFRAMES_ROOT="$2"; fi
if [[ ! -d "$PROJECT/frames_fg" ]]; then
echo "[render] missing matte frames at $PROJECT/frames_fg — run matte.cjs first" >&2
exit 1
@@ -237,49 +226,16 @@ echo "[render] snapshot → history/index-${STAMP}.html"
echo "[render] hyperframes render @ ${FPS}fps"
# Hyperframes occasionally hangs on Chromium shutdown *after* the output file
# is successfully written (seen multiple times on 15–30s clips). Without a
# guard the shell waits forever. This helper enforces a max wall-clock budget,
# and if the output is already on disk when we hit it, treats the run as
# successful and kills the zombie. Tune HF_TIMEOUT_S via env if needed.
# Default SCALES with clip size: two parallel Chromium passes on a long clip
# legitimately exceed a fixed 240s (a 38s/1151-frame render was killed at 244s
# while healthy). ~1.5s per source frame, floor 240s.
N_FRAMES="$(ls "$PROJECT/frames_fg" 2>/dev/null | wc -l | tr -d ' ')"
HF_TIMEOUT_S="${HF_TIMEOUT_S:-$(( N_FRAMES * 3 / 2 > 240 ? N_FRAMES * 3 / 2 : 240 ))}"
# hf_render_dir: render one hyperframes composition.
# args: <output.mp4> <label> <project_dir>
# watches for the Chromium-shutdown-hang; if output file exists and is >1MB
# past timeout, treats as success and kills the zombie.
# Wait for the renderer's real exit status. File size cannot prove completion,
# and a portable process snapshot cannot safely recover a timed-out render tree.
# Cancellation/browser cleanup belongs to the CLI that owns those processes.
hf_render_dir() {
local out="$1" label="$2" proj="$3" fmt="${4:-}"
# bash 3.2 (macOS) throws on empty-array expansion under `set -u`, so branch
# explicitly instead of splatting an optional --format array.
if [[ -n "$fmt" ]]; then
node "$HF_CLI" render --skill=embedded-captions --dir "$proj" --fps "$FPS" --format "$fmt" --crf 11 -o "$out" &
node "$HF_CLI" render --skill=embedded-captions --dir "$proj" --fps "$FPS" --format "$fmt" --crf 11 -o "$out" || return $?
else
node "$HF_CLI" render --skill=embedded-captions --dir "$proj" --fps "$FPS" --crf 11 -o "$out" &
node "$HF_CLI" render --skill=embedded-captions --dir "$proj" --fps "$FPS" --crf 11 -o "$out" || return $?
fi
local pid=$! start=$SECONDS elapsed
while kill -0 "$pid" 2>/dev/null; do
elapsed=$((SECONDS - start))
if (( elapsed > HF_TIMEOUT_S )); then
local sz=0
[[ -f "$out" ]] && sz=$(stat -f%z "$out" 2>/dev/null || echo 0)
if (( sz > 1000000 )); then
echo "[render] ${label}: node hung ${elapsed}s after shutdown (output ${sz}B exists, treating as success)"
kill -9 "$pid" 2>/dev/null
pkill -9 -f "puppeteer_dev_chrome_profile" 2>/dev/null
return 0
fi
echo "[render] ${label}: hung ${elapsed}s with no output — killing and failing" >&2
kill -9 "$pid" 2>/dev/null
pkill -9 -f "puppeteer_dev_chrome_profile" 2>/dev/null
return 2
fi
sleep 5
done
wait "$pid" 2>/dev/null
[[ -f "$out" ]]
}
@@ -22,6 +22,7 @@ const THRESH = 30 / 255; // a cell is "subject" if ≥12% covered at any sampled
const SAMPLES = 48; // frames cached across the clip (windows aggregate the cached grids)
const HF_ROOTS = [
process.argv[2] && path.resolve(process.argv[2]), // project-owned helper dependencies
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "../../.."),
path.join(os.homedir(), "Downloads", "hyperframes"),
@@ -558,7 +559,9 @@ async function main() {
process.exit(2);
}
if (!sharp) {
console.error("[safe-zones] sharp unavailable — set HYPERFRAMES_ROOT");
console.error(
"[safe-zones] sharp unavailable — install project dependencies per embedded-captions SKILL.md Runtime prerequisites",
);
process.exit(0);
}
@@ -9,20 +9,10 @@
*/
const path = require("path");
const fs = require("fs");
const os = require("os");
const cp = require("child_process");
function hfRoot() {
const roots = [
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "..", "..", ".."),
path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
for (const r of roots)
if (fs.existsSync(path.join(r, "packages", "cli", "dist", "cli.js"))) return r;
console.error("[transcribe] hyperframes CLI not found — set HYPERFRAMES_ROOT");
process.exit(3);
}
const { hfCli } = require("./hf-cli.cjs");
function ensureSource(project) {
const src = path.join(project, "source.mp4");
if (fs.existsSync(src)) return src;
@@ -247,7 +237,7 @@ function main() {
if (!words) {
// run hyperframes Whisper → writes a flat word array to <dir>/transcript.json
const cli = path.join(hfRoot(), "packages", "cli", "dist", "cli.js");
const cli = hfCli();
const args = ["transcribe", audio, "-d", project, "--json", "--model", model];
if (language) args.push("--language", language);
let info = {};
@@ -28,7 +28,7 @@ in every workflow and reference:
latest. Existing project dependency files and lockfiles must not be overwritten
to match the plugin; surface a runtime incompatibility before changing them.
- For bundled Node helpers that bootstrap HyperFrames packages, use the same
- For bundled Node helpers, use the same
launcher with `--script <absolute-script-path> <args...>`. This passes the
release version to their dependency loader. External providers, registry
downloads, and existing project dependencies have their own versions; a plugin
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { delimiter, join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
test(
"plugin resolver uses the release CLI without a repository checkout",
{ skip: process.platform === "win32" },
(t) => {
const dir = mkdtempSync(join(tmpdir(), "hf-resolve-plugin-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
mkdirSync(join(dir, "lib"));
for (const file of ["resolve.mjs", "lib/npx-sync.mjs"]) {
copyFileSync(fileURLToPath(new URL(file, import.meta.url)), join(dir, file));
}
writeFileSync(
join(dir, "npx"),
`#!${process.execPath}\nconsole.log(JSON.stringify({args:process.argv.slice(2),skip:process.env.HYPERFRAMES_SKIP_SKILLS}));process.exit(7);`,
{ mode: 0o755 },
);
const result = spawnSync(process.execPath, [join(dir, "resolve.mjs"), "--intent", "a & b"], {
cwd: dir,
encoding: "utf8",
env: {
...process.env,
PATH: `${dir}${delimiter}${process.env.PATH}`,
HYPERFRAMES_PLUGIN_VERSION: "1.2.3",
HYPERFRAMES_SKIP_SKILLS: "1",
},
});
assert.equal(result.status, 7, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), {
args: ["--yes", "hyperframes@1.2.3", "media-use", "resolve", "--intent", "a & b"],
skip: "1",
});
},
);
test("helper package override keeps a contributor's local CLI", (t) => {
const root = mkdtempSync(join(tmpdir(), "hf-resolve-local-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const scripts = join(root, "skills/media-use/scripts");
mkdirSync(join(scripts, "lib"), { recursive: true });
mkdirSync(join(root, "packages/cli/dist"), { recursive: true });
for (const file of ["resolve.mjs", "lib/npx-sync.mjs"]) {
copyFileSync(fileURLToPath(new URL(file, import.meta.url)), join(scripts, file));
}
writeFileSync(join(root, "packages/cli/dist/cli.js"), 'console.log("local-cli");');
const result = spawnSync(process.execPath, [join(scripts, "resolve.mjs")], {
encoding: "utf8",
env: {
...process.env,
HYPERFRAMES_PLUGIN_VERSION: "",
HYPERFRAMES_SKILL_PKG_VERSION: "0.8.79",
},
});
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), "local-cli");
});
+14
View File
@@ -3,6 +3,20 @@ import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveNpxInvocation } from "./lib/npx-sync.mjs";
// Installed plugins have no monorepo packages/ tree. The plugin launcher passes
// the release version and suppression environment through to this legacy shim.
const pluginVersion = process.env.HYPERFRAMES_PLUGIN_VERSION;
if (pluginVersion) {
const invocation = resolveNpxInvocation(
["--yes", `hyperframes@${pluginVersion}`, "media-use", "resolve", ...process.argv.slice(2)],
{ stdio: "inherit" },
);
const result = spawnSync(invocation.cmd, invocation.args, invocation.opts);
if (result.error) console.error(result.error.message);
process.exit(result.status ?? 1);
}
const root = join(fileURLToPath(new URL("../../../", import.meta.url)));
const dist = join(root, "packages/cli/dist/cli.js");
const result = existsSync(dist)