diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 89f596646f..be444b838e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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." } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7e6fd800e..2cf7c8431d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 657c33e924..de0544ae4e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/README.md b/README.md index 7614fe451a..973727c5af 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 ` 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 ` 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 ` 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 diff --git a/docs/contributing/agent-plugins.mdx b/docs/contributing/agent-plugins.mdx new file mode 100644 index 0000000000..aa17b9288e --- /dev/null +++ b/docs/contributing/agent-plugins.mdx @@ -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) diff --git a/docs/docs.json b/docs/docs.json index 3d1301a458..eab955b7ae 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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", diff --git a/docs/guides/plugins.mdx b/docs/guides/plugins.mdx new file mode 100644 index 0000000000..a4344a39f8 --- /dev/null +++ b/docs/guides/plugins.mdx @@ -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 ` 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) diff --git a/docs/guides/skills.mdx b/docs/guides/skills.mdx index dcb9dea6f6..98227b95cd 100644 --- a/docs/guides/skills.mdx +++ b/docs/guides/skills.mdx @@ -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: diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 7410aa6af8..1249998d27 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -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 diff --git a/gemini-extension.json b/gemini-extension.json new file mode 100644 index 0000000000..d2dd91416f --- /dev/null +++ b/gemini-extension.json @@ -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." +} diff --git a/package.json b/package.json index 59cdc04299..dafda9bd95 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 7d333c2707..8168c0f598 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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( diff --git a/packages/cli/src/templates/_shared/AGENTS.md b/packages/cli/src/templates/_shared/AGENTS.md index 45aa30b89f..ff89fb99e5 100644 --- a/packages/cli/src/templates/_shared/AGENTS.md +++ b/packages/cli/src/templates/_shared/AGENTS.md @@ -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 ` 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 ` 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 diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index 45aa30b89f..ff89fb99e5 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -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 ` 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 ` 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 diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000000..67394bfce7 --- /dev/null +++ b/plugin.json @@ -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" +} diff --git a/scripts/agent-plugin-captions.test.mjs b/scripts/agent-plugin-captions.test.mjs new file mode 100644 index 0000000000..3433dd53f4 --- /dev/null +++ b/scripts/agent-plugin-captions.test.mjs @@ -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"), "caption fixture"); + 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"), "fixture"); + 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"); + }); +} diff --git a/scripts/package-agent-plugin.mjs b/scripts/package-agent-plugin.mjs new file mode 100644 index 0000000000..f73fca84a6 --- /dev/null +++ b/scripts/package-agent-plugin.mjs @@ -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)); +} diff --git a/scripts/package-agent-plugin.test.mjs b/scripts/package-agent-plugin.test.mjs new file mode 100644 index 0000000000..de80ba2c9c --- /dev/null +++ b/scripts/package-agent-plugin.test.mjs @@ -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/); +}); diff --git a/scripts/set-version.test.ts b/scripts/set-version.test.ts index d04a05a44b..6d3dff40f4 100644 --- a/scripts/set-version.test.ts +++ b/scripts/set-version.test.ts @@ -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, diff --git a/scripts/set-version.ts b/scripts/set-version.ts index 0f23d61eb7..989120b795 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -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 = ""; @@ -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`), ]; diff --git a/skills-manifest.json b/skills-manifest.json index fd50843d28..27d7b2180f 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -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", diff --git a/skills/embedded-captions/SKILL.md b/skills/embedded-captions/SKILL.md index 800bd86348..c4c704e0d7 100644 --- a/skills/embedded-captions/SKILL.md +++ b/skills/embedded-captions/SKILL.md @@ -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 --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. diff --git a/skills/embedded-captions/scripts/check-occlusion.cjs b/skills/embedded-captions/scripts/check-occlusion.cjs index ae1c6eed72..129d423da5 100644 --- a/skills/embedded-captions/scripts/check-occlusion.cjs +++ b/skills/embedded-captions/scripts/check-occlusion.cjs @@ -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"); diff --git a/skills/embedded-captions/scripts/check-overflow.cjs b/skills/embedded-captions/scripts/check-overflow.cjs index d29fe0e764..4acbaa5d9d 100644 --- a/skills/embedded-captions/scripts/check-overflow.cjs +++ b/skills/embedded-captions/scripts/check-overflow.cjs @@ -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"), diff --git a/skills/embedded-captions/scripts/check-rail-climax.cjs b/skills/embedded-captions/scripts/check-rail-climax.cjs index 5cc4738983..0b921e2991 100644 --- a/skills/embedded-captions/scripts/check-rail-climax.cjs +++ b/skills/embedded-captions/scripts/check-rail-climax.cjs @@ -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" diff --git a/skills/embedded-captions/scripts/hf-cli.cjs b/skills/embedded-captions/scripts/hf-cli.cjs new file mode 100644 index 0000000000..6786380365 --- /dev/null +++ b/skills/embedded-captions/scripts/hf-cli.cjs @@ -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])); diff --git a/skills/embedded-captions/scripts/matte.cjs b/skills/embedded-captions/scripts/matte.cjs index 315c22897b..56b6ec65f5 100644 --- a/skills/embedded-captions/scripts/matte.cjs +++ b/skills/embedded-captions/scripts/matte.cjs @@ -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"); diff --git a/skills/embedded-captions/scripts/measure-layout.cjs b/skills/embedded-captions/scripts/measure-layout.cjs index b0720a58a4..422294bd38 100644 --- a/skills/embedded-captions/scripts/measure-layout.cjs +++ b/skills/embedded-captions/scripts/measure-layout.cjs @@ -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); } diff --git a/skills/embedded-captions/scripts/preview-frames.cjs b/skills/embedded-captions/scripts/preview-frames.cjs index 5e46f17001..ee7c2e9180 100644 --- a/skills/embedded-captions/scripts/preview-frames.cjs +++ b/skills/embedded-captions/scripts/preview-frames.cjs @@ -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); } diff --git a/skills/embedded-captions/scripts/render-and-composite.sh b/skills/embedded-captions/scripts/render-and-composite.sh index 2c9d04e975..56da0fdfa5 100755 --- a/skills/embedded-captions/scripts/render-and-composite.sh +++ b/skills/embedded-captions/scripts/render-and-composite.sh @@ -12,21 +12,10 @@ set -euo pipefail PROJECT="${1:?usage: render-and-composite.sh [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: