* feat(lint): catch leftover marker heads and guessed marked shafts Dash-draw on a marked path shows the arrowhead before the shaft exists. A long marked path that misses every node in both user and screen space is the same detach as the CTM-paste bug, without the counterfactual. * feat(check): flag orphan connectors and unbalanced style tags Catch a visible shaft while fewer than two nodes are on stage (enter-early / exit-late), and extra </style> that dumps CSS onto the frame. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(check): judge connector_orphan by the shaft's own endpoints The rule counted anchors stage-wide and skipped whenever two were on. That tier is every visible text-bearing or opaque element under 15% of the stage, so a title and a footer alone satisfied it — the check could not fire on a composition that had any chrome, which is all of them. Endpoints are now resolved per shaft, against candidates gathered by layout instead of by visibility, so a node hidden with opacity:0 still owns the endpoint that meets it and its visibility is what the rule tests. An endpoint that meets no node is left to connector_detached. * revert(lint): drop the marker rules this repo would only duplicate marker_orient_typo and marker_dash_draw_on already run in Zephyr's Python connector lint, down to the same regexes and the same message and fixHint strings. Zephyr concatenates its local findings onto this bridge's result with no dedupe, so shipping them here reports each one twice and counts it twice, and every later edit has to land in two places. The browser-check work stays: a marked shaft that meets no node, and an orphan shaft, are both runtime facts this repo can see and a source-reading rule cannot. * chore(check): drop the comments added with the orphan rewrite * fix(check): score orphan and detach on overflow-opted connector layers Allow-overflow is a clip opt-out, not a lifecycle skip. Orphan now uses the same connector names as detach so flow and arrow shafts are judged. * fix(check): see axis-aligned shafts, and script tags closed with a space connector_orphan gated on isVisibleElement, which rejects anything whose bounding box has no height or width. A straight horizontal or vertical connector is exactly that, and it is the common shape, so the rule was blind to most of what it exists to catch: across 47 production compositions it produced nothing. It now tests paint -- display, visibility and the opacity chain -- and leaves size to the 80px chord floor it already had. The same 47 compositions now yield three findings, each naming the endpoint that is still dark. unbalanced_style_tags stripped scripts with a regex that only matched `</script>`. `</script >` is valid, so the script survived the strip and a `"</style>"` string literal inside it counted toward the tag balance, reporting an error on a composition whose tags are paired. Both the strip and the closer count now tolerate whitespace before the `>`, which is also what CodeQL flagged on this branch. * fix(lint): count style tags in one pass instead of stripping scripts CodeQL reads the `source.replace(/<script.../)` as an incomplete HTML sanitizer. It was never one — the rule only needs to not count `<style` tokens that live inside a script string. A single alternation scan, where the script branch consumes the whole block, gets the same counts without a replace. * test(check): cover the two orphan guards that no fixture was crossing Neutering `shaftIsPainted`'s opacity check, or the endpoint proximity threshold, left the suite green. Both are false-positive guards: a connector staged hidden before its reveal, and a hidden element that happens to be the nearest thing to an endpoint without belonging to it. The hidden-shaft case now runs over all four ways a shaft can be invisible, so the display and visibility clauses are crossed too. * test: pin the two boundaries raised in review A closer inside an html comment is already dropped upstream by stripHtmlComments, so the rule never sees it. The test fails if that stripping regresses; adding a comment branch to the rule's own scan would have reintroduced the polynomial-redos pattern that stripper exists to avoid. A node hidden with display:none has no geometry, so nothing can place it near an endpoint and connector_orphan stays silent. connector_detached does not pick it up either, since it allows a half-attached shaft by design. That case is uncovered, and the fixture says so. * fix(check): type the hidden-shaft fixture table as a style record * fix(check): key connector_orphan by geometry and let a live node win the endpoint Two defects found in review. connector_orphan was persistence-tiered but absent from the geometry key, so several id-less shafts orphaning at one sample each collapsed into a single finding that then read as held rather than transient. On the 47 corpus compositions this recovers 9 findings across 5 compositions where 3 across 2 were reported; one composition fades its nodes and its connectors on a shared stagger, so four shafts each outlive a different node and only one of the four survived the collapse. The endpoint scan took the nearest candidate and asked whether that one was hidden, without asking whether a visible box was also in range. A staged halo sitting on the node it belongs to produced a finding naming the halo. A visible candidate within threshold now settles the endpoint. Fixtures added for both, and for the two guards a mutation sweep found unpinned: the connector-shape test and the dash-offset skip. * test(check): kill every surviving mutant in the connector_orphan path Seven guards still passed the suite when deleted: the four candidate filters, the connector layer's own visibility gate, the defs/marker skip and the chord floor. Each now has a fixture where that guard alone decides the outcome. The earlier sweep that missed them was mutating the wrong copy of a shared line — connectorAnchorRects and connectorEndpointCandidates carry the same filter text, and a first-match replace edited the detached one. Mutations are scoped by enclosing function now. * test(check): order the halo before the node it hides under The halo fixture proved nothing. Both boxes contain the endpoint, so both score gap 0, and the strict tie-break keeps whichever comes first in document order — which was the visible node. The old code picked the same winner and stayed silent too. Putting the halo first makes the tie-break hand it the slot, so only the visible-candidate check keeps the endpoint attached. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
hyperframes
CLI for creating, previewing, and rendering HTML video compositions.
Install
npm install -g hyperframes
Or use directly with npx:
npx hyperframes <command>
Requirements: Node.js >= 22, FFmpeg
Commands
init
Scaffold a new Hyperframes project from a template:
npx hyperframes init my-video
cd my-video
preview
Start the live preview studio in your browser:
npx hyperframes preview
# Studio: http://localhost:3002/#project/my-video
# Server: http://localhost:3002
npx hyperframes preview --port 4567
In an interactive terminal, the preview stays attached until you press
Ctrl+C. In a non-interactive shell such as a coding-agent session, the same
command starts a managed preview that survives after the command returns. Use
--background or --foreground to choose explicitly, and manage persistent
previews with --status, --stop, --list, and --kill-all. Add --json to
managed lifecycle commands for machine-readable output. --foreground --json
prints the ready-session envelope once, then remains attached until stopped.
normalize-audio
Measure two local authored audio clips with integrated LUFS and match the target
to the unchanged reference. The command is a dry run unless --write is passed:
npx hyperframes normalize-audio --reference target-audio --target user-audio
npx hyperframes normalize-audio --reference target-audio --target user-audio --write
It updates only the target element's data-volume and refuses unsafe boosts
that exceed Studio's +12 dB ceiling or would clip.
render
Render a composition to MP4. Run from the project directory; the positional
argument is the project directory (not a file), so render the project's
index.html directly, or point at a specific composition file with -c:
npx hyperframes render -o output.mp4
npx hyperframes render -c ./my-composition.html -o output.mp4
publish
Upload a project directory and get a hosted URL that keeps working after the CLI exits. Published projects are private by default:
npx hyperframes publish
npx hyperframes publish ./my-video
npx hyperframes publish --public
npx hyperframes publish --yes
Signed-out publishing returns an authentication-required claim URL; opening it
lets someone sign in and claim the project. Sign in first with
npx hyperframes auth login to publish an owned project you can update. Use
--public to make the claimed project visible to anyone. --yes only skips the
confirmation prompt and does not change visibility.
Signed-in publishers can use --update <url-or-id> to target an existing project
or --space <space-id> to publish into a shared team space. If the requested
project is missing or inaccessible, publishing can create a new project instead;
check the printed URL and status.
See the publish reference for all options, including video proxy settings.
lint
Validate your Hyperframes HTML:
npx hyperframes lint ./my-composition
npx hyperframes lint ./my-composition --json # JSON output for CI/tooling
npx hyperframes lint ./my-composition --verbose # Include info-level findings
By default only errors and warnings are shown. Use --verbose to also display informational findings (e.g., external script dependency notices). Use --json for machine-readable output with errorCount, warningCount, infoCount, and a findings array.
compositions
List compositions found in the current project:
npx hyperframes compositions
benchmark
Run rendering benchmarks:
npx hyperframes benchmark ./my-composition.html
doctor
Check your environment for required dependencies (Chrome, FFmpeg, Node.js):
npx hyperframes doctor
browser
Manage the bundled Chrome/Chromium installation:
npx hyperframes browser
info
Print version and environment info:
npx hyperframes info
docs
Open the documentation in your browser:
npx hyperframes docs
upgrade
Check for updates and show upgrade instructions:
npx hyperframes upgrade
npx hyperframes upgrade --check --json # machine-readable for agents
Documentation
Full documentation: hyperframes.heygen.com/packages/cli
Related packages
@hyperframes/core— types, parsers, frame adapters@hyperframes/engine— rendering engine@hyperframes/producer— render pipeline@hyperframes/studio— composition editor UI